From 775863fd3009d84c3d372edbc33adef767f1681a Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:16:48 -0700 Subject: [PATCH] feat(settings): add proxy support for model downloads and update checks (#909) * feat(settings): add proxy support for model downloads and update checks Add a Proxy page to Settings (above About) with Off / System / Manual modes. Manual mode supports SOCKS5 and HTTPS (HTTP CONNECT) proxies with optional username/password auth; the password is stored in config.json in plaintext like the other settings. System mode honors the *_proxy environment variables and falls back to the GNOME org.gnome.system.proxy manual configuration. The resolved proxy is applied to every in-app outbound path that fetches models or checks updates: the shared requests download path in recognition_manager (_stream_model_download, used by the whisper.cpp, whisper, Vosk, Parakeet and faster-whisper downloaders) and the release checks in update_checker. A small Test Connection button on the Proxy page verifies reachability through the configured proxy. SOCKS5 needs PySocks, added as a runtime dependency; uv.lock, the requirements/*.txt exports and the Flatpak dependency manifest are regenerated by just lock (with a fix in sync_flatpak_deps.py for packages like PySocks that publish more than one universal wheel). Fixes #655 * fix(proxy): bracket IPv6 hosts, escape test errors, declare pysocks for AUR --------- Co-authored-by: Jatin K Malik --- packaging/aur/vocalinux/PKGBUILD | 1 + packaging/flatpak/python3-dependencies.yaml | 9 + pyproject.toml | 1 + requirements/dev.txt | 4 + requirements/faster-whisper.txt | 4 + requirements/parakeet.txt | 4 + requirements/runtime.txt | 4 + requirements/vad.txt | 4 + requirements/vosk.txt | 4 + scripts/sync_flatpak_deps.py | 19 +- .../speech_recognition/recognition_manager.py | 2 + src/vocalinux/ui/config_manager.py | 14 + src/vocalinux/ui/settings_dialog.py | 255 ++++++++++++ src/vocalinux/utils/proxy.py | 363 ++++++++++++++++++ src/vocalinux/utils/update_checker.py | 11 +- tests/test_proxy.py | 289 ++++++++++++++ uv.lock | 13 +- 17 files changed, 990 insertions(+), 11 deletions(-) create mode 100644 src/vocalinux/utils/proxy.py create mode 100644 tests/test_proxy.py diff --git a/packaging/aur/vocalinux/PKGBUILD b/packaging/aur/vocalinux/PKGBUILD index bc87e85..378f9de 100644 --- a/packaging/aur/vocalinux/PKGBUILD +++ b/packaging/aur/vocalinux/PKGBUILD @@ -24,6 +24,7 @@ depends=( 'portaudio' 'python-numpy' 'python-requests' + 'python-pysocks' 'python-tqdm' 'python-psutil' 'python-evdev' diff --git a/packaging/flatpak/python3-dependencies.yaml b/packaging/flatpak/python3-dependencies.yaml index ac34ce5..b6864fc 100644 --- a/packaging/flatpak/python3-dependencies.yaml +++ b/packaging/flatpak/python3-dependencies.yaml @@ -113,6 +113,15 @@ modules: - *id004 - *id005 - *id006 + - name: python3-pysocks + buildsystem: simple + build-commands: + - pip3 install --verbose --exists-action=i --no-index --find-links="file://${PWD}" + --prefix=${FLATPAK_DEST} "pysocks" --no-build-isolation + sources: + - type: file + url: https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl + sha256: 2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5 - name: python3-tqdm buildsystem: simple build-commands: diff --git a/pyproject.toml b/pyproject.toml index e4e13be..7bf24fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ "pynput>=1.7.6,<2", "evdev>=1.6.0,<2; sys_platform == 'linux'", "requests>=2.28.0", + "PySocks>=1.7.1,<2", "numpy>=1.22.0,<3", "pyaudio>=0.2.13", "PyGObject; sys_platform == 'linux'", diff --git a/requirements/dev.txt b/requirements/dev.txt index 3db1d9c..a3f7fb6 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -849,6 +849,10 @@ pyobjc-framework-quartz==12.2.2 ; sys_platform == 'darwin' \ # pynput # pyobjc-framework-applicationservices # pyobjc-framework-coretext +pysocks==1.7.1 \ + --hash=sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5 \ + --hash=sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0 + # via vocalinux pytest==9.1.1 \ --hash=sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313 \ --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c diff --git a/requirements/faster-whisper.txt b/requirements/faster-whisper.txt index 1f9601c..cb05cca 100644 --- a/requirements/faster-whisper.txt +++ b/requirements/faster-whisper.txt @@ -574,6 +574,10 @@ pyobjc-framework-quartz==12.2.2 ; sys_platform == 'darwin' \ # pynput # pyobjc-framework-applicationservices # pyobjc-framework-coretext +pysocks==1.7.1 \ + --hash=sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5 \ + --hash=sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0 + # via vocalinux python-xlib==0.33 ; 'linux' in sys_platform \ --hash=sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32 \ --hash=sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398 diff --git a/requirements/parakeet.txt b/requirements/parakeet.txt index 00b3e6d..f601de4 100644 --- a/requirements/parakeet.txt +++ b/requirements/parakeet.txt @@ -413,6 +413,10 @@ pyobjc-framework-quartz==12.2.2 ; sys_platform == 'darwin' \ # pynput # pyobjc-framework-applicationservices # pyobjc-framework-coretext +pysocks==1.7.1 \ + --hash=sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5 \ + --hash=sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0 + # via vocalinux python-xlib==0.33 ; 'linux' in sys_platform \ --hash=sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32 \ --hash=sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398 diff --git a/requirements/runtime.txt b/requirements/runtime.txt index 311794d..ae7bb47 100644 --- a/requirements/runtime.txt +++ b/requirements/runtime.txt @@ -413,6 +413,10 @@ pyobjc-framework-quartz==12.2.2 ; sys_platform == 'darwin' \ # pynput # pyobjc-framework-applicationservices # pyobjc-framework-coretext +pysocks==1.7.1 \ + --hash=sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5 \ + --hash=sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0 + # via vocalinux python-xlib==0.33 ; 'linux' in sys_platform \ --hash=sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32 \ --hash=sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398 diff --git a/requirements/vad.txt b/requirements/vad.txt index c521836..1f936bb 100644 --- a/requirements/vad.txt +++ b/requirements/vad.txt @@ -458,6 +458,10 @@ pyobjc-framework-quartz==12.2.2 ; sys_platform == 'darwin' \ # pynput # pyobjc-framework-applicationservices # pyobjc-framework-coretext +pysocks==1.7.1 \ + --hash=sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5 \ + --hash=sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0 + # via vocalinux python-xlib==0.33 ; 'linux' in sys_platform \ --hash=sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32 \ --hash=sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398 diff --git a/requirements/vosk.txt b/requirements/vosk.txt index d702070..1c24601 100644 --- a/requirements/vosk.txt +++ b/requirements/vosk.txt @@ -507,6 +507,10 @@ pyobjc-framework-quartz==12.2.2 ; sys_platform == 'darwin' \ # pynput # pyobjc-framework-applicationservices # pyobjc-framework-coretext +pysocks==1.7.1 \ + --hash=sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5 \ + --hash=sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0 + # via vocalinux python-xlib==0.33 ; 'linux' in sys_platform \ --hash=sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32 \ --hash=sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398 diff --git a/scripts/sync_flatpak_deps.py b/scripts/sync_flatpak_deps.py index 4dd88e1..29afa9a 100755 --- a/scripts/sync_flatpak_deps.py +++ b/scripts/sync_flatpak_deps.py @@ -133,20 +133,23 @@ def choose_artifact(name: str, version: str, digests: set[str]) -> tuple[str, st if a["packagetype"] == "bdist_wheel" and a["filename"].endswith("-none-any.whl") ] sdists = [a for a in artifacts if a["packagetype"] == "sdist"] - chosen = wheels or sdists - if not chosen: + candidates = wheels or sdists + if not candidates: raise SystemExit( f"{name} {version} publishes neither a universal wheel nor an sdist; " "the Flatpak cannot build it without an ABI-tagged wheel" ) - picked = chosen[0] - sha = picked["digests"]["sha256"] - if sha not in digests: + # A release can publish more than one universal wheel (PySocks 1.7.1 ships + # py27 and py3 builds); take the first whose digest uv actually locked. + picked = next((a for a in candidates if a["digests"]["sha256"] in digests), None) + if picked is None: + first = candidates[0] raise SystemExit( - f"{name} {version}: PyPI serves {picked['filename']} with digest " - f"{sha}, which is not among the hashes uv locked. Re-run `just lock`." + f"{name} {version}: PyPI serves {first['filename']} with digest " + f"{first['digests']['sha256']}, which is not among the hashes uv " + "locked. Re-run `just lock`." ) - return picked["url"], sha + return picked["url"], picked["digests"]["sha256"] def artifact_name(filename: str) -> str: diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index 9fe5fbb..d9d44ad 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -57,6 +57,7 @@ from ..utils.model_checksums import ( write_verification_stamp, ) from ..utils.paths import models_dir +from ..utils.proxy import requests_proxies from ..utils.pywhispercpp_loader import ( find_shared_library_dirs as _find_pywhispercpp_shared_library_dirs, ) @@ -2414,6 +2415,7 @@ class SpeechRecognitionManager: stream=True, timeout=self._MODEL_DOWNLOAD_TIMEOUT, headers={"User-Agent": f"vocalinux/{__version__}"}, + proxies=requests_proxies(), ) except requests.exceptions.RequestException as e: # Surfaced on the calling thread below. diff --git a/src/vocalinux/ui/config_manager.py b/src/vocalinux/ui/config_manager.py index 0abf11a..f50d073 100644 --- a/src/vocalinux/ui/config_manager.py +++ b/src/vocalinux/ui/config_manager.py @@ -242,6 +242,20 @@ DEFAULT_CONFIG = { # Tag last announced via desktop notification (avoids re-notifying every 6h). "last_notified_version": "", }, + # Outbound proxy for model downloads and update checks (#655). "system" + # (the default) is the behavior the app always had: the *_proxy + # environment variables apply, with the GNOME org.gnome.system.proxy + # manual config as a fallback. "off" forces a direct connection and + # "manual" uses the fields below. The password sits in config.json in + # plaintext, the same as speech_recognition.remote_api_key. + "proxy": { + "mode": "system", # "off", "system", or "manual" + "protocol": "socks5", # "socks5" or "https" (HTTP CONNECT proxy) + "host": "", + "port": 1080, + "username": "", + "password": "", + }, # Optional local VocaGateway (podman/docker). Never flips the default engine. "gateway_embed": { "lan_publish": False, # VOCAGATEWAY_PUBLISH_HOST=0.0.0.0 when True (Phone on LAN) diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 6158d21..7a603f7 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -58,6 +58,14 @@ from ..utils.model_choice import ( size_for_priority, ) from ..utils.paths import models_dir # noqa: E402 +from ..utils.proxy import ( # noqa: E402 + DEFAULT_PROXY_MODE, + DEFAULT_PROXY_PROTOCOL, + default_port_for_protocol, + normalize_proxy_mode, + normalize_proxy_protocol, + requests_proxies, +) from ..utils.system_language import ( # noqa: E402 LANGUAGE_FOLLOWS_LAYOUT, language_for_active_layout, @@ -2709,6 +2717,7 @@ class SettingsDialog(Gtk.Dialog): SettingsPage("application", "Application", "preferences-system-symbolic"), SettingsPage("advanced", "Advanced", "applications-engineering-symbolic"), SettingsPage("post-processing", "Post-Processing", "utilities-terminal-symbolic"), + SettingsPage("proxy", "Proxy", "preferences-system-network-proxy-symbolic"), SettingsPage("about", "About", "help-about-symbolic"), ] pages_by_name = {page.name: page for page in self._pages} @@ -2724,6 +2733,7 @@ class SettingsDialog(Gtk.Dialog): self.general_tab = pages_by_name["application"].box self.advanced_tab = pages_by_name["advanced"].box self.post_processing_tab = pages_by_name["post-processing"].box + self.proxy_tab = pages_by_name["proxy"].box self.about_tab = pages_by_name["about"].box # Each page is wrapped in a vertical ScrolledWindow: without one, the @@ -2819,6 +2829,7 @@ class SettingsDialog(Gtk.Dialog): self._build_general_section() self._build_advanced_section() self._build_post_processing_section() + self._build_proxy_section() self._build_about_section() self._build_sidebar_footer(sidebar_box) @@ -6262,6 +6273,250 @@ class SettingsDialog(Gtk.Dialog): button.connect("clicked", lambda *_args, dest=url: self._open_web_url(dest)) return button + def _build_proxy_section(self) -> None: + """Build the Proxy page: Off / System / Manual modes (#655). + + The mode applies to model downloads and update checks. The manual + rows stay visible but insensitive unless the mode is Manual. + """ + self.proxy_group = PreferencesGroup( + title="Proxy", + description=( + "Route model downloads and update checks through a proxy. " + "Useful in regions where the model hosts are unreachable." + ), + keywords=("proxy", "network", "socks5", "https"), + ) + + self.proxy_mode_combo = Gtk.ComboBoxText() + _style_combo(self.proxy_mode_combo) + _combo_chrome( + self.proxy_mode_combo, + tooltip="How Vocalinux connects to model hosts and update checks", + ) + self.proxy_mode_combo.append("off", "Off") + self.proxy_mode_combo.append("system", "System") + self.proxy_mode_combo.append("manual", "Manual") + mode_row = PreferenceRow( + title="Mode", + subtitle=( + "System uses the *_proxy environment variables and the GNOME " + "proxy settings; Manual uses the server configured below" + ), + widget=self.proxy_mode_combo, + keywords=("mode",), + ) + self.proxy_group.add_row(mode_row) + self.proxy_tab.pack_start(self.proxy_group, False, False, 0) + + self.proxy_manual_group = PreferencesGroup( + title="Manual Proxy", + description="Applied when the mode above is set to Manual.", + ) + + self.proxy_protocol_combo = Gtk.ComboBoxText() + _style_combo(self.proxy_protocol_combo) + _combo_chrome( + self.proxy_protocol_combo, + tooltip="SOCKS5 or an HTTPS (HTTP CONNECT) proxy", + ) + self.proxy_protocol_combo.append("socks5", "SOCKS5") + self.proxy_protocol_combo.append("https", "HTTPS") + protocol_row = PreferenceRow( + title="Protocol", + subtitle="SOCKS5 or an HTTPS (HTTP CONNECT) proxy", + widget=self.proxy_protocol_combo, + keywords=("protocol", "socks5", "https"), + ) + self.proxy_manual_group.add_row(protocol_row) + + self.proxy_host_entry = Gtk.Entry() + self.proxy_host_entry.set_placeholder_text("127.0.0.1 or proxy.example.com") + self.proxy_host_entry.set_tooltip_text("Proxy server hostname or IP address") + self.proxy_host_entry.set_size_request(_CONTROL_WIDTH, -1) + host_row = PreferenceRow( + title="Host", + subtitle="Proxy server hostname or IP address", + widget=self.proxy_host_entry, + keywords=("host", "server"), + ) + self.proxy_manual_group.add_row(host_row) + + self.proxy_port_spin = _new_spin( + 1, + 65535, + 1, + 0, + tooltip="Proxy server port (SOCKS5 default 1080, HTTPS often 8080)", + ) + port_row = PreferenceRow( + title="Port", + subtitle="Proxy server port", + widget=self.proxy_port_spin, + keywords=("port",), + ) + self.proxy_manual_group.add_row(port_row) + + self.proxy_username_entry = Gtk.Entry() + self.proxy_username_entry.set_placeholder_text("(optional)") + self.proxy_username_entry.set_tooltip_text("Proxy username (optional)") + self.proxy_username_entry.set_size_request(_CONTROL_WIDTH, -1) + username_row = PreferenceRow( + title="Username", + subtitle="Proxy username (optional)", + widget=self.proxy_username_entry, + keywords=("username", "auth"), + ) + self.proxy_manual_group.add_row(username_row) + + self.proxy_password_entry = Gtk.Entry() + self.proxy_password_entry.set_placeholder_text("(optional)") + self.proxy_password_entry.set_visibility(False) + self.proxy_password_entry.set_tooltip_text( + "Proxy password (optional); stored in config.json in plaintext, " + "the same as the remote API key" + ) + self.proxy_password_entry.set_size_request(_CONTROL_WIDTH, -1) + password_row = PreferenceRow( + title="Password", + subtitle="Proxy password (optional)", + widget=self.proxy_password_entry, + keywords=("password", "auth"), + ) + self.proxy_manual_group.add_row(password_row) + + self.proxy_test_btn = Gtk.Button(label="Test Connection") + self.proxy_test_btn.set_tooltip_text( + "Check that the Hugging Face model host is reachable with these settings" + ) + self.proxy_test_btn.connect("clicked", self._on_test_proxy_connection) + test_row = PreferenceRow( + title="Connection Test", + subtitle="Verify the Hugging Face model host is reachable", + widget=self.proxy_test_btn, + keywords=("test", "connection"), + ) + self.proxy_manual_group.add_row(test_row) + self.proxy_tab.pack_start(self.proxy_manual_group, False, False, 0) + + # Status line under the groups, matching the remote server section + self.proxy_status_label = Gtk.Label(label="", use_markup=True, xalign=0) + self.proxy_status_label.set_margin_start(16) + self.proxy_status_label.set_margin_top(4) + self.proxy_status_label.get_style_context().add_class("status-info") + self.proxy_tab.pack_start(self.proxy_status_label, False, False, 0) + + # Load saved values into the widgets + proxy_settings = self.config_manager.get_settings().get("proxy", {}) + saved_mode = normalize_proxy_mode(proxy_settings.get("mode")) + if not self.proxy_mode_combo.set_active_id(saved_mode): + self.proxy_mode_combo.set_active_id(DEFAULT_PROXY_MODE) + saved_protocol = normalize_proxy_protocol(proxy_settings.get("protocol")) + if not self.proxy_protocol_combo.set_active_id(saved_protocol): + self.proxy_protocol_combo.set_active_id(DEFAULT_PROXY_PROTOCOL) + saved_host = proxy_settings.get("host", "") + if saved_host: + self.proxy_host_entry.set_text(str(saved_host)) + try: + saved_port = int(proxy_settings.get("port") or 0) + except (TypeError, ValueError): + saved_port = 0 + if saved_port < 1 or saved_port > 65535: + saved_port = default_port_for_protocol(saved_protocol) + self.proxy_port_spin.set_value(saved_port) + saved_username = proxy_settings.get("username", "") + if saved_username: + self.proxy_username_entry.set_text(str(saved_username)) + saved_password = proxy_settings.get("password", "") + if saved_password: + self.proxy_password_entry.set_text(str(saved_password)) + + self.proxy_mode_combo.connect("changed", self._on_proxy_mode_changed) + self.proxy_protocol_combo.connect("changed", self._on_proxy_settings_changed) + self.proxy_host_entry.connect("changed", self._on_proxy_settings_changed) + self.proxy_port_spin.connect("value-changed", self._on_proxy_settings_changed) + self.proxy_username_entry.connect("changed", self._on_proxy_settings_changed) + self.proxy_password_entry.connect("changed", self._on_proxy_settings_changed) + + self._update_proxy_sensitivity() + + def _on_proxy_mode_changed(self, widget: Gtk.Widget) -> None: + """Persist the proxy mode and gate the manual rows on it.""" + if _handlers_suppressed(self): + return + self._save_proxy_settings() + self._update_proxy_sensitivity() + + def _on_proxy_settings_changed(self, widget: Gtk.Widget) -> None: + """Persist the manual proxy fields as they are edited.""" + if _handlers_suppressed(self): + return + self._save_proxy_settings() + + def _save_proxy_settings(self) -> None: + """Write the proxy widgets to the ``proxy`` config section.""" + self.config_manager.set( + "proxy", "mode", self.proxy_mode_combo.get_active_id() or DEFAULT_PROXY_MODE + ) + self.config_manager.set( + "proxy", + "protocol", + self.proxy_protocol_combo.get_active_id() or DEFAULT_PROXY_PROTOCOL, + ) + self.config_manager.set("proxy", "host", self.proxy_host_entry.get_text().strip()) + self.config_manager.set("proxy", "port", int(self.proxy_port_spin.get_value())) + self.config_manager.set("proxy", "username", self.proxy_username_entry.get_text().strip()) + self.config_manager.set("proxy", "password", self.proxy_password_entry.get_text()) + self.config_manager.save_config() + + def _update_proxy_sensitivity(self) -> None: + """Gray out the manual fields unless the mode is Manual.""" + manual = (self.proxy_mode_combo.get_active_id() or DEFAULT_PROXY_MODE) == "manual" + self.proxy_manual_group.set_sensitive(manual) + + def _on_test_proxy_connection(self, widget: Gtk.Widget) -> None: + """Fetch the Hugging Face model repo through the configured proxy.""" + self.proxy_test_btn.set_sensitive(False) + self.proxy_test_btn.set_label("Testing...") + self.proxy_status_label.set_markup("Connecting...") + + config = { + "mode": self.proxy_mode_combo.get_active_id() or DEFAULT_PROXY_MODE, + "protocol": self.proxy_protocol_combo.get_active_id() or DEFAULT_PROXY_PROTOCOL, + "host": self.proxy_host_entry.get_text().strip(), + "port": int(self.proxy_port_spin.get_value()), + "username": self.proxy_username_entry.get_text().strip(), + "password": self.proxy_password_entry.get_text(), + } + + def test_connection(config: dict = config) -> None: + import requests + + try: + response = requests.get( + "https://huggingface.co/api/models/ggerganov/whisper.cpp", + headers={"User-Agent": f"vocalinux/{__version__}"}, + timeout=10, + proxies=requests_proxies(config), + ) + GLib.idle_add( + self.proxy_status_label.set_markup, + "✓ huggingface.co reachable " + f"(status={response.status_code})", + ) + except requests.RequestException as exc: + logger.warning("Proxy connection test against huggingface.co failed: %s", exc) + error_msg = GLib.markup_escape_text(str(exc)[:80]) + GLib.idle_add( + self.proxy_status_label.set_markup, + f"✗ Connection failed: {error_msg}", + ) + + GLib.idle_add(self.proxy_test_btn.set_sensitive, True) + GLib.idle_add(self.proxy_test_btn.set_label, "Test Connection") + + threading.Thread(target=test_connection, daemon=True).start() + def _build_about_section(self): """Build the About page using the same PreferenceRow cards as other pages.""" from gi.repository import GdkPixbuf diff --git a/src/vocalinux/utils/proxy.py b/src/vocalinux/utils/proxy.py new file mode 100644 index 0000000..07a4da3 --- /dev/null +++ b/src/vocalinux/utils/proxy.py @@ -0,0 +1,363 @@ +"""Outbound-proxy configuration shared by Vocalinux network calls. + +Model downloads and update checks read the ``proxy`` section of config.json +through this module so every outbound path honors the same setting: + +- ``off`` — connect directly, even when the environment declares a proxy. +- ``system`` — inherit the ``*_proxy`` environment variables (the requests and + urllib default), falling back to the GNOME ``org.gnome.system.proxy`` manual + configuration when the environment declares none. GNOME PAC ("auto") mode + is out of scope. +- ``manual`` — the protocol/host/port (and optional credentials) configured on + the Settings Proxy page. ``socks5`` is routed as ``socks5h`` so DNS resolves + at the proxy — local lookups are poisoned or censored in exactly the regions + this feature serves. ``https`` follows the GNOME/Firefox "HTTPS proxy" + convention: a plain HTTP CONNECT proxy carrying HTTPS traffic, expressed as + an ``http://`` proxy URL applied to http and https targets. +""" + +import logging +import os +import subprocess +from typing import Any, Dict, Mapping, Optional +from urllib.parse import quote +from urllib.request import ProxyHandler + +from .host_process import host_env + +logger = logging.getLogger(__name__) + +PROXY_MODES = ("off", "system", "manual") +PROXY_PROTOCOLS = ("socks5", "https") + +#: "system" preserves the behavior the app has always had: requests and urllib +#: already honor the *_proxy environment variables. +DEFAULT_PROXY_MODE = "system" +DEFAULT_PROXY_PROTOCOL = "socks5" + +#: Conventional ports: Settings seeds these and a saved config missing a port +#: falls back to them. +DEFAULT_SOCKS5_PORT = 1080 +DEFAULT_HTTPS_PROXY_PORT = 8080 + +_PROXY_ENV_VARS = ( + "http_proxy", + "https_proxy", + "ftp_proxy", + "all_proxy", + "no_proxy", + "HTTP_PROXY", + "HTTPS_PROXY", + "FTP_PROXY", + "ALL_PROXY", + "NO_PROXY", +) + + +def normalize_proxy_mode(value: Any) -> str: + """Return a supported proxy mode id; unknown or missing becomes the default.""" + if isinstance(value, str) and value.strip().casefold() in PROXY_MODES: + return value.strip().casefold() + return DEFAULT_PROXY_MODE + + +def normalize_proxy_protocol(value: Any) -> str: + """Return a supported manual protocol id; unknown or missing becomes socks5.""" + if isinstance(value, str) and value.strip().casefold() in PROXY_PROTOCOLS: + return value.strip().casefold() + return DEFAULT_PROXY_PROTOCOL + + +def _saved_proxy_section() -> Mapping[str, Any]: + """Return the saved ``proxy`` config section, or an empty mapping. + + The shared manager is consulted lazily so this module stays importable + without the GTK stack, and so tests can bypass it by passing a config + mapping explicitly. + """ + try: + from ..ui.config_manager import get_shared_config_manager + except ImportError: # pragma: no cover - defensive + return {} + try: + section = get_shared_config_manager().get_settings().get("proxy", {}) + except (OSError, TypeError, ValueError, AttributeError) as exc: + logger.debug("Saved proxy configuration is not readable: %s", exc) + return {} + return section if isinstance(section, Mapping) else {} + + +def effective_proxy_config(config: Optional[Mapping[str, Any]] = None) -> Dict[str, Any]: + """Return normalized proxy settings. + + ``config`` is the raw ``proxy`` config section; when omitted, the saved + section of the shared ConfigManager is used. + """ + raw = dict(config) if isinstance(config, Mapping) else dict(_saved_proxy_section()) + try: + port = int(raw.get("port") or 0) + except (TypeError, ValueError): + port = 0 + if port < 0 or port > 65535: + port = 0 + return { + "mode": normalize_proxy_mode(raw.get("mode")), + "protocol": normalize_proxy_protocol(raw.get("protocol")), + "host": str(raw.get("host") or "").strip(), + "port": port, + "username": str(raw.get("username") or ""), + "password": str(raw.get("password") or ""), + } + + +def default_port_for_protocol(protocol: Any) -> int: + """Return the conventional listen port for a manual protocol.""" + if normalize_proxy_protocol(protocol) == "socks5": + return DEFAULT_SOCKS5_PORT + return DEFAULT_HTTPS_PROXY_PORT + + +def _bracket_ipv6_host(host: str) -> str: + """Wrap a bare IPv6 literal in brackets for URL building. + + ``::1`` must become ``[::1]`` before ``:port`` is appended; hostnames and + already-bracketed literals pass through. + """ + if host.startswith("[") or ":" not in host: + return host + return f"[{host}]" + + +def manual_proxy_url(config: Optional[Mapping[str, Any]] = None) -> Optional[str]: + """Return the manual proxy URL, or None when no host is configured. + + Credentials are percent-encoded into the URL when a username is set. + """ + cfg = effective_proxy_config(config) + if not cfg["host"]: + return None + scheme = "socks5h" if cfg["protocol"] == "socks5" else "http" + auth = "" + if cfg["username"]: + auth = quote(cfg["username"], safe="") + if cfg["password"]: + auth += ":" + quote(cfg["password"], safe="") + auth += "@" + port = cfg["port"] or default_port_for_protocol(cfg["protocol"]) + host = _bracket_ipv6_host(cfg["host"]) + return f"{scheme}://{auth}{host}:{port}" + + +def _env_declares_proxy(env: Optional[Mapping[str, str]] = None) -> bool: + """Return whether the environment already declares an HTTP-family proxy.""" + source = os.environ if env is None else env + return any( + source.get(name) + for name in ( + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + ) + ) + + +def _gsettings_proxy_values() -> Dict[tuple, str]: + """Dump ``org.gnome.system.proxy`` as ``{(schema, key): raw_value}``.""" + try: + result = subprocess.run( + ["gsettings", "list-recursively", "org.gnome.system.proxy"], + capture_output=True, + text=True, + timeout=5, + env=host_env(), + ) + except (OSError, subprocess.SubprocessError) as exc: + logger.debug("GNOME proxy lookup via gsettings failed: %s", exc) + return {} + if result.returncode != 0 or not result.stdout: + return {} + values: Dict[tuple, str] = {} + for line in result.stdout.splitlines(): + schema, _, rest = line.partition(" ") + key, _, raw = rest.partition(" ") + if schema and key: + values[(schema, key)] = raw.strip() + return values + + +def _unquote_gvariant(value: str) -> str: + """Strip the single quotes gsettings puts around string values.""" + value = value.strip() + if len(value) >= 2 and value.startswith("'") and value.endswith("'"): + return value[1:-1] + return value + + +def _gnome_string_list(value: str) -> list: + """Parse a gsettings ``['a', 'b']`` string list into Python strings.""" + value = value.strip() + if not (value.startswith("[") and value.endswith("]")): + return [] + return [item for item in (_unquote_gvariant(part) for part in value[1:-1].split(",")) if item] + + +def _gnome_system_proxy() -> Optional[Dict[str, Any]]: + """Return GNOME's manual proxy as ``{"url": ..., "no_proxy": ...}``, or None. + + Only ``mode = 'manual'`` maps onto one URL; PAC ("auto") needs a JS engine + to evaluate and stays out of scope. The HTTPS pair wins (our downloads are + HTTPS), then HTTP — which GNOME also applies to HTTPS targets under + use-same-proxy — then SOCKS. + """ + values = _gsettings_proxy_values() + base = "org.gnome.system.proxy" + if _unquote_gvariant(values.get((base, "mode"), "")) != "manual": + return None + + def _host_port(child: str) -> tuple: + host = _unquote_gvariant(values.get((f"{base}.{child}", "host"), "")) + try: + port = int(values.get((f"{base}.{child}", "port"), "0")) + except ValueError: + port = 0 + return host, port + + no_proxy = ",".join(_gnome_string_list(values.get((base, "ignore-hosts"), ""))) + + https_host, https_port = _host_port("https") + if https_host: + host = _bracket_ipv6_host(https_host) + return {"url": f"http://{host}:{https_port}", "no_proxy": no_proxy} + + http_host, http_port = _host_port("http") + if http_host: + auth = "" + # The GNOME schema carries credentials only on the http pair. + if values.get((f"{base}.http", "use-authentication"), "").strip() == "true": + user = _unquote_gvariant(values.get((f"{base}.http", "authentication-user"), "")) + password = _unquote_gvariant( + values.get((f"{base}.http", "authentication-password"), "") + ) + if user: + auth = quote(user, safe="") + if password: + auth += ":" + quote(password, safe="") + auth += "@" + host = _bracket_ipv6_host(http_host) + return {"url": f"http://{auth}{host}:{http_port}", "no_proxy": no_proxy} + + socks_host, socks_port = _host_port("socks") + if socks_host: + host = _bracket_ipv6_host(socks_host) + return {"url": f"socks5h://{host}:{socks_port}", "no_proxy": no_proxy} + return None + + +def _system_proxy() -> Optional[Dict[str, Any]]: + """System-mode proxy: env vars when present, else the GNOME manual config.""" + if _env_declares_proxy(): + # requests and urllib pick the variables up on their own. + return None + return _gnome_system_proxy() + + +def requests_proxies( + config: Optional[Mapping[str, Any]] = None, +) -> Optional[Dict[str, str]]: + """Return the ``proxies=`` argument for a ``requests`` call under the mode. + + - ``off``: ``{"no_proxy": "*"}``, a bypass that wins even over proxy + variables merged in from the environment. + - ``system``: ``None`` when the environment declares proxies (requests + honors them natively); an explicit map when only GNOME settings do. + - ``manual``: the configured URL applied to both schemes; a missing host + means a direct connection rather than silently falling back to env. + """ + cfg = effective_proxy_config(config) + if cfg["mode"] == "off": + return {"no_proxy": "*"} + if cfg["mode"] == "manual": + url = manual_proxy_url(cfg) + if url is None: + logger.warning("Manual proxy mode is enabled without a host; connecting directly") + return {"no_proxy": "*"} + return {"http": url, "https": url} + system = _system_proxy() + if system is None: + return None + proxies: Dict[str, str] = {"http": system["url"], "https": system["url"]} + if system.get("no_proxy"): + proxies["no_proxy"] = system["no_proxy"] + return proxies + + +def urllib_proxy_handler(config: Optional[Mapping[str, Any]] = None) -> ProxyHandler: + """Return a ``urllib.request.ProxyHandler`` honoring the saved mode. + + ``off`` maps to ``ProxyHandler({})`` — the documented way to disable even + environment-declared proxies; ``system`` maps to ``ProxyHandler()`` with an + explicit GNOME map when needed; ``manual`` maps to the configured URL. + urllib cannot speak SOCKS, so a manual socks5 URL still fails for callers + that use this handler; the requests paths are the primary network surface. + """ + cfg = effective_proxy_config(config) + if cfg["mode"] == "off": + return ProxyHandler({}) + if cfg["mode"] == "manual": + url = manual_proxy_url(cfg) + if url is None: + logger.warning("Manual proxy mode is enabled without a host; connecting directly") + return ProxyHandler({}) + return ProxyHandler({"http": url, "https": url}) + system = _system_proxy() + if system is None: + return ProxyHandler() + return ProxyHandler({"http": system["url"], "https": system["url"]}) + + +def proxy_env_vars(config: Optional[Mapping[str, Any]] = None) -> Dict[str, str]: + """Return the ``*_proxy`` environment variables implied by the saved mode. + + Only ``manual`` returns a mapping: the conventional upper- and lowercase + variables set to the configured URL, so env-reading libraries and spawned + subprocesses see the same proxy. ``system`` returns empty (inherit the + process environment as-is) and ``off``/hostless ``manual`` return empty + (callers strip inherited proxy variables so nothing leaks through). + """ + cfg = effective_proxy_config(config) + if cfg["mode"] != "manual": + return {} + url = manual_proxy_url(cfg) + if url is None: + return {} + return { + "HTTP_PROXY": url, + "HTTPS_PROXY": url, + "ALL_PROXY": url, + "http_proxy": url, + "https_proxy": url, + "all_proxy": url, + } + + +def proxy_env( + base: Optional[Mapping[str, str]] = None, + config: Optional[Mapping[str, Any]] = None, +) -> Dict[str, str]: + """Return a copy of ``base`` (default ``os.environ``) with proxy vars applied. + + ``off`` strips inherited proxy variables; ``manual`` replaces them with the + configured URL; ``system`` leaves the environment untouched. ``os.environ`` + itself is never mutated — pass the result to subprocess ``env=``. + """ + env = dict(os.environ if base is None else base) + cfg = effective_proxy_config(config) + if cfg["mode"] == "system": + return env + for name in _PROXY_ENV_VARS: + env.pop(name, None) + env.update(proxy_env_vars(cfg)) + return env diff --git a/src/vocalinux/utils/update_checker.py b/src/vocalinux/utils/update_checker.py index dc209e3..ea47b07 100644 --- a/src/vocalinux/utils/update_checker.py +++ b/src/vocalinux/utils/update_checker.py @@ -7,6 +7,7 @@ from typing import Optional from urllib.parse import urlparse from ..version import __url__ +from .proxy import requests_proxies logger = logging.getLogger(__name__) @@ -215,7 +216,7 @@ def _fetch_stable_from_releases_page( "User-Agent": "Vocalinux-UpdateChecker", } try: - response = requests.get(url, headers=headers, timeout=timeout) + response = requests.get(url, headers=headers, timeout=timeout, proxies=requests_proxies()) final_url = getattr(response, "url", "") or "" parsed = urlparse(final_url) if ( @@ -258,7 +259,12 @@ def fetch_latest_release( try: if channel == "stable": - response = requests.get(f"{api_base}/releases/latest", headers=headers, timeout=timeout) + response = requests.get( + f"{api_base}/releases/latest", + headers=headers, + timeout=timeout, + proxies=requests_proxies(), + ) if response.status_code == 404: logger.warning("No latest release found for %s", api_base) return None @@ -279,6 +285,7 @@ def fetch_latest_release( headers=headers, params={"per_page": 30, "page": page}, timeout=timeout, + proxies=requests_proxies(), ) if response.status_code in (403, 429): logger.debug("GitHub API rate-limited or forbidden for %s", api_base) diff --git a/tests/test_proxy.py b/tests/test_proxy.py new file mode 100644 index 0000000..a769607 --- /dev/null +++ b/tests/test_proxy.py @@ -0,0 +1,289 @@ +"""Tests for the proxy resolution layer (#655).""" + +import os +from typing import Any, Dict, Optional +from unittest.mock import MagicMock, patch + +from vocalinux.utils.proxy import ( + DEFAULT_HTTPS_PROXY_PORT, + DEFAULT_PROXY_MODE, + DEFAULT_PROXY_PROTOCOL, + DEFAULT_SOCKS5_PORT, + default_port_for_protocol, + effective_proxy_config, + manual_proxy_url, + normalize_proxy_mode, + normalize_proxy_protocol, + proxy_env, + proxy_env_vars, + requests_proxies, + urllib_proxy_handler, +) + + +def _manual_config(**overrides: Any) -> Dict[str, Any]: + """Build a manual-mode proxy config dict with sensible defaults.""" + config: Dict[str, Any] = { + "mode": "manual", + "protocol": "socks5", + "host": "proxy.example.com", + "port": 1080, + "username": "", + "password": "", + } + config.update(overrides) + return config + + +class TestModeAndProtocolParsing: + def test_default_mode_is_system(self) -> None: + assert DEFAULT_PROXY_MODE == "system" + assert normalize_proxy_mode(None) == "system" + assert normalize_proxy_mode("") == "system" + + def test_mode_normalization(self) -> None: + assert normalize_proxy_mode("Off") == "off" + assert normalize_proxy_mode(" MANUAL ") == "manual" + assert normalize_proxy_mode("bogus") == "system" + + def test_default_protocol_is_socks5(self) -> None: + assert DEFAULT_PROXY_PROTOCOL == "socks5" + assert normalize_proxy_protocol(None) == "socks5" + assert normalize_proxy_protocol("") == "socks5" + + def test_protocol_normalization(self) -> None: + assert normalize_proxy_protocol("HTTPS") == "https" + assert normalize_proxy_protocol("bogus") == "socks5" + + def test_default_ports(self) -> None: + assert default_port_for_protocol("socks5") == DEFAULT_SOCKS5_PORT + assert default_port_for_protocol("https") == DEFAULT_HTTPS_PROXY_PORT + assert default_port_for_protocol("bogus") == DEFAULT_SOCKS5_PORT + + +class TestManualProxyUrl: + def test_socks5_uses_remote_dns_scheme(self) -> None: + url = manual_proxy_url(_manual_config()) + assert url == "socks5h://proxy.example.com:1080" + + def test_https_uses_http_connect_scheme(self) -> None: + url = manual_proxy_url(_manual_config(protocol="https", port=8080)) + assert url == "http://proxy.example.com:8080" + + def test_auth_is_url_encoded(self) -> None: + url = manual_proxy_url(_manual_config(username="us er", password="p@ss:word")) + assert url == "socks5h://us%20er:p%40ss%3Aword@proxy.example.com:1080" + + def test_username_without_password(self) -> None: + url = manual_proxy_url(_manual_config(username="alice")) + assert url == "socks5h://alice@proxy.example.com:1080" + + def test_password_without_username_is_ignored(self) -> None: + # Proxy auth always needs a user; a bare password must not produce + # a "user-less" authority component. + url = manual_proxy_url(_manual_config(password="secret")) + assert url == "socks5h://proxy.example.com:1080" + + def test_missing_host_returns_none(self) -> None: + assert manual_proxy_url(_manual_config(host="")) is None + assert manual_proxy_url(_manual_config(host=" ")) is None + + def test_invalid_port_falls_back_to_default(self) -> None: + url = manual_proxy_url(_manual_config(port="abc")) + assert url == "socks5h://proxy.example.com:1080" + + def test_out_of_range_port_falls_back(self) -> None: + url = manual_proxy_url(_manual_config(port=99999)) + assert url == "socks5h://proxy.example.com:1080" + + def test_ipv6_host_is_bracketed(self) -> None: + url = manual_proxy_url(_manual_config(host="::1")) + assert url == "socks5h://[::1]:1080" + + def test_ipv6_host_already_bracketed_passes_through(self) -> None: + url = manual_proxy_url(_manual_config(host="[2001:db8::1]")) + assert url == "socks5h://[2001:db8::1]:1080" + + +class TestEffectiveProxyConfig: + def test_reads_saved_section(self) -> None: + saved = {"proxy": {"mode": "manual", "host": "h", "port": 3128}} + with patch( + "vocalinux.utils.proxy._saved_proxy_section", + return_value=dict(saved["proxy"]), + ): + cfg = effective_proxy_config() + assert cfg["mode"] == "manual" + assert cfg["host"] == "h" + assert cfg["port"] == 3128 + + def test_passed_config_wins_over_saved(self) -> None: + with patch( + "vocalinux.utils.proxy._saved_proxy_section", + return_value={"mode": "off", "host": "x"}, + ): + cfg = effective_proxy_config({"mode": "manual", "host": "h"}) + assert cfg["mode"] == "manual" + assert cfg["host"] == "h" + + def test_saved_values_are_normalized(self) -> None: + with patch( + "vocalinux.utils.proxy._saved_proxy_section", + return_value={"mode": "MANUAL", "protocol": "HTTPS", "host": " h "}, + ): + cfg = effective_proxy_config() + assert cfg["mode"] == "manual" + assert cfg["protocol"] == "https" + assert cfg["host"] == "h" + + +class TestRequestsProxies: + def test_off_bypasses_env_proxies(self) -> None: + # {"no_proxy": "*"} is how requests is told to connect directly even + # when *_proxy environment variables are set. + proxies = requests_proxies({"mode": "off"}) + assert proxies == {"no_proxy": "*"} + + def test_system_with_env_returns_none(self) -> None: + # requests already honors the environment; None means "do that". + with patch.dict(os.environ, {"HTTPS_PROXY": "http://10.0.0.1:3128"}, clear=False): + assert requests_proxies({"mode": "system"}) is None + + def test_system_without_env_reads_gnome(self) -> None: + with ( + patch.dict(os.environ, {}, clear=True), + patch( + "vocalinux.utils.proxy._gnome_system_proxy", + return_value={"url": "http://g:8080", "no_proxy": "localhost"}, + ), + ): + proxies = requests_proxies({"mode": "system"}) + assert proxies == { + "http": "http://g:8080", + "https": "http://g:8080", + "no_proxy": "localhost", + } + + def test_system_without_env_or_gnome_is_direct(self) -> None: + with ( + patch.dict(os.environ, {}, clear=True), + patch("vocalinux.utils.proxy._gnome_system_proxy", return_value=None), + ): + assert requests_proxies({"mode": "system"}) is None + + def test_manual_returns_http_and_https(self) -> None: + proxies = requests_proxies(_manual_config()) + assert proxies == { + "http": "socks5h://proxy.example.com:1080", + "https": "socks5h://proxy.example.com:1080", + } + + def test_manual_without_host_goes_direct(self) -> None: + # A saved manual mode with an empty host must not silently fall back + # to env proxies the user meant to bypass. + proxies = requests_proxies(_manual_config(host="")) + assert proxies == {"no_proxy": "*"} + + +class TestUrllibProxyHandler: + def test_off_disables_proxies(self) -> None: + handler = urllib_proxy_handler({"mode": "off"}) + assert handler.proxies == {} + + def test_system_uses_environment(self) -> None: + with ( + patch.dict(os.environ, {}, clear=True), + patch("vocalinux.utils.proxy._gnome_system_proxy", return_value=None), + ): + handler = urllib_proxy_handler({"mode": "system"}) + # No proxies kwarg means urllib reads the environment itself. + assert handler.proxies == {} + + def test_manual_maps_to_http_and_https(self) -> None: + handler = urllib_proxy_handler(_manual_config(protocol="https", port=8080)) + assert handler.proxies == { + "http": "http://proxy.example.com:8080", + "https": "http://proxy.example.com:8080", + } + + +class TestProxyEnv: + def test_manual_sets_upper_and_lower_vars(self) -> None: + env = proxy_env_vars(_manual_config()) + url = "socks5h://proxy.example.com:1080" + assert env == { + "HTTP_PROXY": url, + "HTTPS_PROXY": url, + "ALL_PROXY": url, + "http_proxy": url, + "https_proxy": url, + "all_proxy": url, + } + + def test_off_and_system_set_nothing(self) -> None: + assert proxy_env_vars({"mode": "off"}) == {} + assert proxy_env_vars({"mode": "system", "host": "h"}) == {} + + def test_manual_without_host_sets_nothing(self) -> None: + assert proxy_env_vars(_manual_config(host="")) == {} + + def test_proxy_env_never_mutates_os_environ(self) -> None: + base = {"PATH": "/bin", "HTTP_PROXY": "http://old:1"} + with patch.dict(os.environ, {}, clear=True): + merged = proxy_env(base, _manual_config()) + # The process environment is untouched: callers pass the returned + # copy to subprocess env= instead of mutating os.environ. + assert os.environ == {} + assert base["HTTP_PROXY"] == "http://old:1" + assert merged["PATH"] == "/bin" + assert merged["HTTP_PROXY"] == "socks5h://proxy.example.com:1080" + + +class TestGnomeSystemProxy: + """GNOME org.gnome.system.proxy parsing (manual mode only).""" + + def _run_proxy( + self, stdout: str, env: Optional[Dict[str, str]] = None + ) -> Optional[Dict[str, str]]: + from vocalinux.utils.proxy import _gnome_system_proxy + + fake = MagicMock() + fake.returncode = 0 + fake.stdout = stdout + with patch("vocalinux.utils.proxy.subprocess.run", return_value=fake): + return _gnome_system_proxy() + + def test_none_mode_returns_none(self) -> None: + proxies = self._run_proxy("org.gnome.system.proxy mode 'none'\n") + assert proxies is None + + def test_auto_mode_is_unsupported(self) -> None: + proxies = self._run_proxy("org.gnome.system.proxy mode 'auto'\n") + assert proxies is None + + def test_manual_mode_builds_url(self) -> None: + stdout = ( + "org.gnome.system.proxy mode 'manual'\n" + "org.gnome.system.proxy.http host 'g.proxy'\n" + "org.gnome.system.proxy.http port 8080\n" + "org.gnome.system.proxy.http enabled true\n" + "org.gnome.system.proxy.https host ''\n" + "org.gnome.system.proxy.https port 0\n" + "org.gnome.system.proxy.socks host ''\n" + "org.gnome.system.proxy.socks port 0\n" + "org.gnome.system.proxy ignore-hosts ['localhost', '127.0.0.1']\n" + ) + proxies = self._run_proxy(stdout) + assert proxies == { + "url": "http://g.proxy:8080", + "no_proxy": "localhost,127.0.0.1", + } + + def test_gsettings_failure_returns_none(self) -> None: + from vocalinux.utils.proxy import _gnome_system_proxy + + with patch( + "vocalinux.utils.proxy.subprocess.run", + side_effect=OSError("gsettings not found"), + ): + assert _gnome_system_proxy() is None diff --git a/uv.lock b/uv.lock index 81570eb..ef21cd9 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.15' and sys_platform != 'ios'", @@ -2059,6 +2059,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b9/8e/d883718e872abc214ba053f60f7e6edc2abd5ffbd1544beb01a9c92ddd3c/pyproject_metadata-0.12.1-py3-none-any.whl", hash = "sha256:f7162d580a96386a8eb096da06215f981f547d1490f03055ef99e323bc2da427", size = 23452, upload-time = "2026-07-04T02:19:30.657Z" }, ] +[[package]] +name = "pysocks" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" }, +] + [[package]] name = "pytest" version = "9.1.1" @@ -2946,6 +2955,7 @@ dependencies = [ { name = "pyaudio" }, { name = "pygobject", marker = "sys_platform == 'linux'" }, { name = "pynput" }, + { name = "pysocks" }, { name = "pywhispercpp" }, { name = "requests" }, ] @@ -3012,6 +3022,7 @@ requires-dist = [ { name = "pyaudio", specifier = ">=0.2.13" }, { name = "pygobject", marker = "sys_platform == 'linux'" }, { name = "pynput", specifier = ">=1.7.6,<2" }, + { name = "pysocks", specifier = ">=1.7.1,<2" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" }, { name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.10.0" },