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:
co-authored by
Jatin K Malik
parent
442a646a57
commit
775863fd30
@@ -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:
|
||||
|
||||
@@ -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'",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user