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 <jatinkrmalik@gmail.com>
This commit is contained in:
devin-ai-integration[bot]
2026-10-07 17:16:48 -07:00
committed by GitHub
co-authored by Jatin K Malik
parent 442a646a57
commit 775863fd30
17 changed files with 990 additions and 11 deletions
+1
View File
@@ -24,6 +24,7 @@ depends=(
'portaudio'
'python-numpy'
'python-requests'
'python-pysocks'
'python-tqdm'
'python-psutil'
'python-evdev'
@@ -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:
+1
View File
@@ -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'",
+4
View File
@@ -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
+4
View File
@@ -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
+4
View File
@@ -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
+4
View File
@@ -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
+4
View File
@@ -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
+4
View File
@@ -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
+11 -8
View File
@@ -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:
@@ -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.
+14
View File
@@ -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)
+255
View File
@@ -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("<i>Connecting...</i>")
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,
"<span foreground='#26a269'>✓ huggingface.co reachable "
f"(status={response.status_code})</span>",
)
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"<span foreground='#c01c28'>✗ Connection failed: {error_msg}</span>",
)
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
+363
View File
@@ -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
+9 -2
View File
@@ -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)
+289
View File
@@ -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
Generated
+12 -1
View File
@@ -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" },