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" },