mirror of
https://github.com/Flowseal/tg-ws-proxy.git
synced 2026-10-21 11:08:44 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
307a384ab7 | ||
|
|
241d76d7b9 | ||
|
|
7460091c19 | ||
|
|
9deed3707a | ||
|
|
c52714f264 | ||
|
|
03be9baad1 | ||
|
|
c317406b52 | ||
|
|
bf07b42f88 | ||
|
|
a6177fec87 | ||
|
|
5f8820d41f | ||
|
|
3cda0f5446 | ||
|
|
59c3a6e3b3 | ||
|
|
9109570d32 | ||
|
|
00d72a4c7f | ||
|
|
a0a92f5e7e | ||
|
|
a232472cbe | ||
|
|
3fa281625f | ||
|
|
3c54c54d36 | ||
|
|
03421197c8 |
No files matched your search
@@ -12,6 +12,11 @@ on:
|
||||
description: "Release version tag (e.g. v1.0.0)"
|
||||
required: false
|
||||
default: "v1.0.0"
|
||||
sign_windows:
|
||||
description: 'Sign?'
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
build_windows_x64:
|
||||
description: 'Build Windows x64?'
|
||||
type: boolean
|
||||
@@ -39,8 +44,15 @@ on:
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
|
||||
env:
|
||||
SIGNPATH_ORGANIZATION_ID: d0164909-65ed-4ca8-aa9f-ec148a9f7950
|
||||
SIGNPATH_PROJECT_SLUG: tg-ws-proxy
|
||||
SIGNPATH_ARTIFACT_CONFIGURATION_SLUG: initial
|
||||
SIGNPATH_SIGNING_POLICY: ${{ github.event.inputs.make_release == 'true' && 'release-signing' || 'test-signing' }}
|
||||
|
||||
jobs:
|
||||
build-windows-x64:
|
||||
runs-on: windows-latest
|
||||
@@ -96,11 +108,36 @@ jobs:
|
||||
- name: Rename artifact
|
||||
run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows.exe
|
||||
|
||||
- name: Upload artifact
|
||||
- name: Upload unsigned artifact
|
||||
if: ${{ inputs.sign_windows }}
|
||||
id: upload-unsigned
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: unsigned-windows-x64
|
||||
path: dist/TgWsProxy_windows.exe
|
||||
archive: true
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Sign EXE with SignPath
|
||||
if: ${{ inputs.sign_windows }}
|
||||
uses: signpath/github-action-submit-signing-request@v3
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
organization-id: ${{ env.SIGNPATH_ORGANIZATION_ID }}
|
||||
project-slug: ${{ env.SIGNPATH_PROJECT_SLUG }}
|
||||
signing-policy-slug: ${{ env.SIGNPATH_SIGNING_POLICY }}
|
||||
artifact-configuration-slug: ${{ env.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }}
|
||||
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
|
||||
wait-for-completion: true
|
||||
output-artifact-directory: signed
|
||||
|
||||
- name: Upload Windows artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: TgWsProxy-windows-x64
|
||||
path: dist/TgWsProxy_windows.exe
|
||||
path: ${{ inputs.sign_windows && 'signed' || 'dist' }}/TgWsProxy_windows.exe
|
||||
archive: true
|
||||
if-no-files-found: error
|
||||
|
||||
build-windows-arm64:
|
||||
runs-on: windows-11-arm
|
||||
@@ -173,11 +210,36 @@ jobs:
|
||||
- name: Rename artifact
|
||||
run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows_arm64.exe
|
||||
|
||||
- name: Upload artifact
|
||||
- name: Upload unsigned artifact
|
||||
if: ${{ inputs.sign_windows }}
|
||||
id: upload-unsigned
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: unsigned-windows-arm64
|
||||
path: dist/TgWsProxy_windows_arm64.exe
|
||||
archive: true
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Sign EXE with SignPath
|
||||
if: ${{ inputs.sign_windows }}
|
||||
uses: signpath/github-action-submit-signing-request@v3
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
organization-id: ${{ env.SIGNPATH_ORGANIZATION_ID }}
|
||||
project-slug: ${{ env.SIGNPATH_PROJECT_SLUG }}
|
||||
signing-policy-slug: ${{ env.SIGNPATH_SIGNING_POLICY }}
|
||||
artifact-configuration-slug: ${{ env.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }}
|
||||
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
|
||||
wait-for-completion: true
|
||||
output-artifact-directory: signed
|
||||
|
||||
- name: Upload Windows artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: TgWsProxy-windows-arm64
|
||||
path: dist/TgWsProxy_windows_arm64.exe
|
||||
path: ${{ inputs.sign_windows && 'signed' || 'dist' }}/TgWsProxy_windows_arm64.exe
|
||||
archive: true
|
||||
if-no-files-found: error
|
||||
|
||||
build-win7:
|
||||
runs-on: windows-latest
|
||||
@@ -227,11 +289,36 @@ jobs:
|
||||
- name: Rename artifact
|
||||
run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows_7_${{ matrix.suffix }}.exe
|
||||
|
||||
- name: Upload artifact
|
||||
- name: Upload unsigned artifact
|
||||
if: ${{ inputs.sign_windows }}
|
||||
id: upload-unsigned
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: unsigned-win7-${{ matrix.suffix }}
|
||||
path: dist/TgWsProxy_windows_7_${{ matrix.suffix }}.exe
|
||||
archive: true
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Sign EXE with SignPath
|
||||
if: ${{ inputs.sign_windows }}
|
||||
uses: signpath/github-action-submit-signing-request@v3
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
organization-id: ${{ env.SIGNPATH_ORGANIZATION_ID }}
|
||||
project-slug: ${{ env.SIGNPATH_PROJECT_SLUG }}
|
||||
signing-policy-slug: ${{ env.SIGNPATH_SIGNING_POLICY }}
|
||||
artifact-configuration-slug: ${{ env.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }}
|
||||
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
|
||||
wait-for-completion: true
|
||||
output-artifact-directory: signed
|
||||
|
||||
- name: Upload Windows artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: TgWsProxy-win7-${{ matrix.suffix }}
|
||||
path: dist/TgWsProxy_windows_7_${{ matrix.suffix }}.exe
|
||||
path: ${{ inputs.sign_windows && 'signed' || 'dist' }}/TgWsProxy_windows_7_${{ matrix.suffix }}.exe
|
||||
archive: true
|
||||
if-no-files-found: error
|
||||
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
@@ -573,7 +660,7 @@ jobs:
|
||||
name: "TG WS Proxy ${{ github.event.inputs.version }}"
|
||||
body: |
|
||||
---
|
||||
### [❤️ Поддержать развитие проекта](https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/Funding.md)
|
||||
### [❤️ Поддержать развитие проекта](https://github.com/Flowseal/tg-ws-proxy/blob/main/docs/RU/Funding.md)
|
||||
|
||||
> [!TIP]
|
||||
> Не можете скачать?
|
||||
|
||||
@@ -55,11 +55,12 @@ tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
|
||||
| `--no-secure` | `false` | Use 80 port for CF-proxy and CF-worker connections |
|
||||
| `--fake-tls-domain` | | Enable Fake TLS masquerading (ee-secret) with specified SNI domain |
|
||||
| `--proxy-protocol` | disabled | Accept HAProxy PROXY protocol v1 (for use behind nginx/haproxy with `proxy_protocol on`) |
|
||||
| `--force-test-dc` | `false` | Route all traffic to Telegram test DCs [Learn more](./TestDc.md) |
|
||||
| `--buf-kb` | `256` | Buffer size in KB |
|
||||
| `--pool-size` | `4` | Number of ready WS connections per DC. `0` disables the direct DC→IP WS route |
|
||||
| `--log-file` | disabled | Path to file for saving logs |
|
||||
| `--log-max-mb` | `5` | Maximum log file size in MB (afterwards overwrites) |
|
||||
| `--log-backups` | `0` | Number of log backups after overwrite |
|
||||
| `--log-backups` | `1` | Number of rotated log files to keep (minimum 1) |
|
||||
| `-v`, `--verbose` | disabled | Verbose logging (DEBUG) |
|
||||
|
||||
**Examples:**
|
||||
|
||||
@@ -32,6 +32,17 @@ chmod +x TgWsProxy_linux_amd64
|
||||
|
||||
On first launch, a window will open with instructions. The application runs in the system tray (AppIndicator required).
|
||||
|
||||
## Updating
|
||||
|
||||
When update checks are enabled in settings, the app offers to update itself on startup and installs the new version:
|
||||
|
||||
- installed from `.deb`/`.rpm` — the new package is downloaded and installed by the package manager (via `pkexec`, password confirmation required);
|
||||
- standalone binary — replaced in place if its directory is writable, otherwise also via `pkexec`.
|
||||
|
||||
The downloaded file is checked against the response length and the SHA-256 digest from the release; a corrupted download is never installed. The app restarts automatically after the update.
|
||||
|
||||
For AUR packages, `pip` installs, and non-x86_64 architectures the previous behaviour applies — the release page is opened.
|
||||
|
||||
## Configuring Telegram Desktop
|
||||
|
||||
1. Telegram → **Settings** → **Advanced** → **Connection type** → **Proxy**
|
||||
|
||||
@@ -18,13 +18,18 @@ The tray application stores data in:
|
||||
"verbose": false,
|
||||
"buf_kb": 256,
|
||||
"pool_size": 4,
|
||||
"log_max_mb": 5.0,
|
||||
"log_max_mb": 5,
|
||||
"check_updates": true,
|
||||
"cfproxy": true,
|
||||
"cfproxy_user_domain": "",
|
||||
"cfproxy_worker_domain": "",
|
||||
"h2": true,
|
||||
"cfproxy_user_domain_enabled": false,
|
||||
"cfproxy_user_domain": [],
|
||||
"cfproxy_worker_enabled": false,
|
||||
"cfproxy_worker_domain": [],
|
||||
"force_test_dc": false,
|
||||
"appearance": "auto"
|
||||
"no_secure": false,
|
||||
"appearance": "auto",
|
||||
"language": "en"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -55,11 +55,12 @@ tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v]
|
||||
| `--no-secure` | `false` | Принудительно использует 80 порт для CF-proxy и CF-worker |
|
||||
| `--fake-tls-domain` | | Включить маскировку Fake TLS (ee-secret) с указанным SNI-доменом |
|
||||
| `--proxy-protocol` | выкл. | Принимать HAProxy PROXY protocol v1 (для работы за nginx/haproxy с `proxy_protocol on`) |
|
||||
| `--force-test-dc` | `false` | Направлять весь трафик на тестовые DC Telegram [Подробнее](./TestDc.md) |
|
||||
| `--buf-kb` | `256` | Размер буфера в КБ |
|
||||
| `--pool-size` | `4` | Количество заготовленных WS-соединений на каждый DC. `0` отключает прямой WS-маршрут DC→IP |
|
||||
| `--log-file` | выкл. | Путь к файлу, в который будут сохраняться логи |
|
||||
| `--log-max-mb` | `5` | Максимальный размер файла логов в МБ (после этого начинается перезапись) |
|
||||
| `--log-backups` | `0` | Количество сохранений логов после перезаписи |
|
||||
| `--log-backups` | `1` | Сколько старых файлов логов хранить при ротации (минимум 1) |
|
||||
| `-v`, `--verbose` | выкл. | Подробное логирование (DEBUG) |
|
||||
|
||||
**Примеры:**
|
||||
|
||||
@@ -32,6 +32,17 @@ chmod +x TgWsProxy_linux_amd64
|
||||
|
||||
При первом запуске откроется окно с инструкцией. Приложение работает в системном трее (требуется AppIndicator).
|
||||
|
||||
## Обновление
|
||||
|
||||
Если в настройках включена проверка обновлений, при запуске приложение само предложит обновиться и установит новую версию:
|
||||
|
||||
- установка из `.deb`/`.rpm` — скачивается новый пакет и ставится пакетным менеджером (через `pkexec`, потребуется подтверждение пароля);
|
||||
- отдельный бинарный файл — заменяется на месте, если каталог доступен на запись, иначе тоже через `pkexec`.
|
||||
|
||||
Загруженный файл проверяется по длине ответа и контрольной сумме SHA-256 из релиза, повреждённая загрузка не устанавливается. После установки приложение перезапускается автоматически.
|
||||
|
||||
Для пакетов из AUR, установки через `pip` и архитектур, отличных от x86_64, обновление выполняется прежним способом — открывается страница релиза.
|
||||
|
||||
## Настройка Telegram Desktop
|
||||
|
||||
1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси**
|
||||
|
||||
@@ -18,13 +18,18 @@ Tray-приложение хранит данные в:
|
||||
"verbose": false,
|
||||
"buf_kb": 256,
|
||||
"pool_size": 4,
|
||||
"log_max_mb": 5.0,
|
||||
"log_max_mb": 5,
|
||||
"check_updates": true,
|
||||
"cfproxy": true,
|
||||
"cfproxy_user_domain": "",
|
||||
"cfproxy_worker_domain": "",
|
||||
"h2": true,
|
||||
"cfproxy_user_domain_enabled": false,
|
||||
"cfproxy_user_domain": [],
|
||||
"cfproxy_worker_enabled": false,
|
||||
"cfproxy_worker_domain": [],
|
||||
"force_test_dc": false,
|
||||
"appearance": "auto"
|
||||
"no_secure": false,
|
||||
"appearance": "auto",
|
||||
"language": "ru"
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import webbrowser
|
||||
from typing import Optional
|
||||
|
||||
import customtkinter as ctk
|
||||
@@ -12,13 +13,13 @@ import pyperclip
|
||||
import pystray
|
||||
from PIL import Image, ImageTk
|
||||
|
||||
from proxy import get_link_host
|
||||
from proxy import __version__, get_link_host
|
||||
|
||||
from utils.tray_common import (
|
||||
APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, LOG_FILE,
|
||||
acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog,
|
||||
ensure_ctk_thread, ensure_dirs, load_config, load_icon, log,
|
||||
maybe_notify_update, quit_ctk, release_lock, restart_proxy,
|
||||
APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IS_FROZEN, LOG_FILE,
|
||||
acquire_lock, bootstrap, check_ipv6_warning, check_update_async,
|
||||
ctk_run_dialog, ensure_ctk_thread, ensure_dirs, load_config, load_icon,
|
||||
log, quit_ctk, release_lock, restart_proxy,
|
||||
save_config, start_proxy, stop_proxy, tg_proxy_url,
|
||||
)
|
||||
from ui.ctk_tray_ui import (
|
||||
@@ -31,10 +32,12 @@ from ui.ctk_theme import (
|
||||
)
|
||||
from ui.i18n import t
|
||||
from ui.settings_dialog import SettingsDialog
|
||||
from utils.update_check import get_status, relaunch_env
|
||||
|
||||
_tray_icon: Optional[object] = None
|
||||
_config: dict = {}
|
||||
_exiting = False
|
||||
_update_flow_lock = threading.Lock()
|
||||
|
||||
# dialogs (tkinter messagebox)
|
||||
|
||||
@@ -161,6 +164,7 @@ def _edit_config_dialog() -> None:
|
||||
widgets = install_tray_config_form(
|
||||
ctk, scroll, theme, cfg, DEFAULT_CONFIG,
|
||||
show_autostart=False,
|
||||
on_update_click=_trigger_update_flow,
|
||||
)
|
||||
|
||||
def _finish() -> None:
|
||||
@@ -215,6 +219,78 @@ def _show_first_run() -> None:
|
||||
ctk_run_dialog(_build)
|
||||
|
||||
|
||||
# update
|
||||
|
||||
|
||||
def _restart_after_update() -> None:
|
||||
from utils.linux_update import exe_path
|
||||
|
||||
stop_proxy()
|
||||
release_lock()
|
||||
exe = str(exe_path())
|
||||
try:
|
||||
subprocess.Popen(
|
||||
[exe, *sys.argv[1:]], env=relaunch_env(),
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
stdin=subprocess.DEVNULL, start_new_session=True,
|
||||
)
|
||||
except OSError as exc:
|
||||
log.error("Failed to launch updated binary: %s", exc)
|
||||
if acquire_lock():
|
||||
start_proxy(_config, _show_error)
|
||||
_show_error(t("update.error", msg=exc))
|
||||
return
|
||||
os._exit(0)
|
||||
|
||||
|
||||
def _trigger_update_flow(icon=None, item=None) -> None:
|
||||
if not _update_flow_lock.acquire(blocking=False):
|
||||
return
|
||||
|
||||
def _work() -> None:
|
||||
from utils.linux_update import asset_name, detect_install_kind
|
||||
from utils.update_check import find_asset, RELEASES_PAGE_URL
|
||||
|
||||
try:
|
||||
st = get_status()
|
||||
if _exiting or not st["has_update"]:
|
||||
return
|
||||
if _tray_icon is not None:
|
||||
_tray_icon.menu = _build_menu()
|
||||
kind = detect_install_kind() if IS_FROZEN else None
|
||||
asset = find_asset(asset_name(kind)) if kind else None
|
||||
page_url = st["html_url"] or RELEASES_PAGE_URL
|
||||
if not asset:
|
||||
if _ask_yes_no(t("update.ask_open", version=st["latest"]), t("app.update_title")):
|
||||
webbrowser.open(page_url)
|
||||
return
|
||||
|
||||
if _ask_yes_no(t("update.ask_install", version=st["latest"]), t("app.update_title")):
|
||||
_install_update(kind, asset, page_url)
|
||||
except Exception:
|
||||
log.exception("Update flow failed")
|
||||
finally:
|
||||
_update_flow_lock.release()
|
||||
|
||||
threading.Thread(target=_work, daemon=True, name="manual-update").start()
|
||||
|
||||
|
||||
def _install_update(kind: str, asset: dict, page_url: str) -> None:
|
||||
from utils.linux_update import apply_update
|
||||
|
||||
err = apply_update(
|
||||
kind, asset["url"], digest=asset.get("digest", ""), log=log,
|
||||
)
|
||||
if err is None:
|
||||
log.info("Update installed, restarting")
|
||||
_restart_after_update()
|
||||
return
|
||||
|
||||
log.error("Update failed: %s", err)
|
||||
if _ask_yes_no(t("update.install_fail", error=err), t("app.update_title")):
|
||||
webbrowser.open(page_url)
|
||||
|
||||
|
||||
# tray menu
|
||||
|
||||
|
||||
@@ -222,7 +298,7 @@ def _build_menu():
|
||||
host = _config.get("host", DEFAULT_CONFIG["host"])
|
||||
port = _config.get("port", DEFAULT_CONFIG["port"])
|
||||
link_host = get_link_host(host)
|
||||
return pystray.Menu(
|
||||
items = [
|
||||
pystray.MenuItem(t("tray.open_telegram", host=link_host, port=port), _on_open_in_telegram, default=True),
|
||||
pystray.MenuItem(t("tray.copy_link"), _on_copy_link),
|
||||
pystray.Menu.SEPARATOR,
|
||||
@@ -231,7 +307,13 @@ def _build_menu():
|
||||
pystray.MenuItem(t("tray.logs"), _on_open_logs),
|
||||
pystray.Menu.SEPARATOR,
|
||||
pystray.MenuItem(t("tray.exit"), _on_exit),
|
||||
)
|
||||
]
|
||||
st = get_status()
|
||||
if st["has_update"]:
|
||||
items.insert(-2, pystray.MenuItem(
|
||||
t("tray.update", current=__version__, new=st["latest"]), _trigger_update_flow,
|
||||
))
|
||||
return pystray.Menu(*items)
|
||||
|
||||
|
||||
# entry point
|
||||
@@ -254,7 +336,7 @@ def run_tray() -> None:
|
||||
return
|
||||
|
||||
start_proxy(_config, _show_error)
|
||||
maybe_notify_update(_config, lambda: _exiting, _ask_yes_no)
|
||||
check_update_async(_config, lambda: _exiting, lambda version, url: _trigger_update_flow())
|
||||
_show_first_run()
|
||||
check_ipv6_warning(_show_info)
|
||||
|
||||
|
||||
@@ -2,15 +2,14 @@
|
||||
|
||||
import os
|
||||
import glob
|
||||
|
||||
from PyInstaller.utils.hooks import collect_data_files
|
||||
import certifi
|
||||
|
||||
block_cipher = None
|
||||
|
||||
# customtkinter ships JSON themes + assets that must be bundled
|
||||
import customtkinter
|
||||
ctk_path = os.path.dirname(customtkinter.__file__)
|
||||
certifi_datas = collect_data_files('certifi')
|
||||
certifi_cafile = certifi.where()
|
||||
|
||||
_i18n_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'ui', 'i18n')
|
||||
|
||||
@@ -25,7 +24,7 @@ a = Analysis(
|
||||
[os.path.join(os.path.dirname(SPEC), os.pardir, 'linux.py')],
|
||||
pathex=[],
|
||||
binaries=appindicator_binaries,
|
||||
datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')] + certifi_datas,
|
||||
datas=[(os.path.join(ctk_path, 'assets'), 'customtkinter/assets'), (_i18n_path, 'ui/i18n')],
|
||||
hiddenimports=[
|
||||
'httpx',
|
||||
'h2',
|
||||
@@ -63,6 +62,8 @@ a = Analysis(
|
||||
cipher=block_cipher,
|
||||
)
|
||||
|
||||
a.datas += [('certifi/cacert.pem', certifi_cafile, 'DATA')]
|
||||
|
||||
_required_libraries = {
|
||||
'libglib-2.0.so.0', 'libgobject-2.0.so.0', 'libgio-2.0.so.0',
|
||||
'libgtk-3.so.0', 'libappindicator3.so.1',
|
||||
|
||||
@@ -15,7 +15,7 @@ a = Analysis(
|
||||
[os.path.join(os.path.dirname(SPEC), os.pardir, 'macos.py')],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')] + certifi_datas,
|
||||
datas=[(os.path.join(ctk_path, 'assets'), 'customtkinter/assets'), (_i18n_path, 'ui/i18n')] + certifi_datas,
|
||||
hiddenimports=[
|
||||
'httpx',
|
||||
'h2',
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
# http://msdn.microsoft.com/en-us/library/ms646997.aspx
|
||||
VSVersionInfo(
|
||||
ffi=FixedFileInfo(
|
||||
filevers=(1, 11, 1, 0),
|
||||
prodvers=(1, 11, 1, 0),
|
||||
filevers=(1, 11, 2, 0),
|
||||
prodvers=(1, 11, 2, 0),
|
||||
mask=0x3f,
|
||||
flags=0x0,
|
||||
OS=0x40004,
|
||||
@@ -21,12 +21,12 @@ VSVersionInfo(
|
||||
[
|
||||
StringStruct(u'CompanyName', u'Flowseal'),
|
||||
StringStruct(u'FileDescription', u'Telegram Desktop WebSocket Bridge Proxy'),
|
||||
StringStruct(u'FileVersion', u'1.11.1.0'),
|
||||
StringStruct(u'FileVersion', u'1.11.2.0'),
|
||||
StringStruct(u'InternalName', u'TgWsProxy'),
|
||||
StringStruct(u'LegalCopyright', u'Copyright (c) Flowseal. MIT License.'),
|
||||
StringStruct(u'OriginalFilename', u'TgWsProxy.exe'),
|
||||
StringStruct(u'ProductName', u'TG WS Proxy'),
|
||||
StringStruct(u'ProductVersion', u'1.11.1.0'),
|
||||
StringStruct(u'ProductVersion', u'1.11.2.0'),
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
@@ -18,7 +18,7 @@ a = Analysis(
|
||||
[os.path.join(os.path.dirname(SPEC), os.pardir, 'windows.py')],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[(ctk_path, 'customtkinter/'), (_i18n_path, 'ui/i18n')] + certifi_datas,
|
||||
datas=[(os.path.join(ctk_path, 'assets'), 'customtkinter/assets'), (_i18n_path, 'ui/i18n')] + certifi_datas,
|
||||
hiddenimports=[
|
||||
'httpx',
|
||||
'h2',
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
from .config import parse_dc_ip_list, proxy_config, coerce_domain_list
|
||||
from .utils import get_link_host, build_github_opener
|
||||
|
||||
__version__ = "1.11.1"
|
||||
__version__ = "1.11.2"
|
||||
|
||||
__all__ = ["__version__", "get_link_host", "proxy_config", "parse_dc_ip_list", "build_github_opener", "coerce_domain_list"]
|
||||
+10
-18
@@ -12,7 +12,6 @@ from .balancer import balancer
|
||||
from .config import proxy_config
|
||||
from .raw_websocket import RawWebSocket
|
||||
from .pool import cf_worker_pool
|
||||
from ._aes import Cipher, algorithms, modes
|
||||
from .network_debug import WsActivity
|
||||
from .cf_h2 import bridge_h2
|
||||
|
||||
@@ -32,8 +31,6 @@ def reset_tcp_backoff() -> None:
|
||||
_tcp_retry_after.clear()
|
||||
_tcp_connecting.clear()
|
||||
|
||||
ZERO_64 = b'\x00' * 64
|
||||
|
||||
|
||||
class CryptoCtx:
|
||||
__slots__ = ('clt_dec', 'clt_enc', 'tg_enc', 'tg_dec')
|
||||
@@ -50,44 +47,39 @@ class MsgSplitter:
|
||||
Splits TCP stream data into individual MTProto transport packets
|
||||
so each can be sent as a separate WS frame.
|
||||
"""
|
||||
__slots__ = ('_dec', '_proto', '_cipher_buf', '_plain_buf', '_disabled')
|
||||
__slots__ = ('_proto', '_cipher_buf', '_plain_buf', '_disabled')
|
||||
|
||||
def __init__(self, relay_init: bytes, proto_int: int):
|
||||
cipher = Cipher(algorithms.AES(relay_init[8:40]),
|
||||
modes.CTR(relay_init[40:56]))
|
||||
self._dec = cipher.encryptor()
|
||||
self._dec.update(ZERO_64)
|
||||
def __init__(self, proto_int: int):
|
||||
self._proto = proto_int
|
||||
self._cipher_buf = bytearray()
|
||||
self._plain_buf = bytearray()
|
||||
self._disabled = False
|
||||
|
||||
def split(self, chunk: bytes) -> List[bytes]:
|
||||
def split(self, chunk: bytes, plain: bytes) -> List[bytes]:
|
||||
if not chunk:
|
||||
return []
|
||||
if self._disabled:
|
||||
return [chunk]
|
||||
|
||||
self._cipher_buf.extend(chunk)
|
||||
self._plain_buf.extend(self._dec.update(chunk))
|
||||
self._plain_buf.extend(plain)
|
||||
|
||||
parts = []
|
||||
offset = 0
|
||||
buf_len = len(self._cipher_buf)
|
||||
# Walk the buffer with an offset instead of deleting each packet from
|
||||
# the front. Front-deletion on a bytearray shifts the remaining bytes,
|
||||
# so a chunk holding many small packets degrades to O(N^2); a single
|
||||
# trailing del keeps splitting O(N).
|
||||
while offset < buf_len:
|
||||
packet_len = self._next_packet_len(offset, buf_len - offset)
|
||||
if packet_len is None:
|
||||
break
|
||||
if packet_len <= 0:
|
||||
parts.append(bytes(self._cipher_buf[offset:]))
|
||||
parts.append(bytes(self._cipher_buf) if offset == 0 else bytes(self._cipher_buf[offset:]))
|
||||
offset = buf_len
|
||||
self._disabled = True
|
||||
break
|
||||
parts.append(bytes(self._cipher_buf[offset:offset + packet_len]))
|
||||
if offset == 0 and packet_len == buf_len:
|
||||
parts.append(bytes(self._cipher_buf))
|
||||
else:
|
||||
parts.append(bytes(self._cipher_buf[offset:offset + packet_len]))
|
||||
offset += packet_len
|
||||
|
||||
if offset:
|
||||
@@ -365,7 +357,7 @@ async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
|
||||
plain = ctx.clt_dec.update(chunk)
|
||||
chunk = ctx.tg_enc.update(plain)
|
||||
if splitter:
|
||||
parts = splitter.split(chunk)
|
||||
parts = splitter.split(chunk, plain)
|
||||
if not parts:
|
||||
continue
|
||||
if activity is not None:
|
||||
|
||||
+22
-9
@@ -16,11 +16,12 @@ from .h2_transport import H2Transport
|
||||
from .stats import stats
|
||||
from .utils import (
|
||||
DC_DEFAULT_IPS, PROTO_TAG_ABRIDGED,
|
||||
PROTO_TAG_SECURE, create_ssl_context,
|
||||
PROTO_TAG_SECURE,
|
||||
)
|
||||
|
||||
log = logging.getLogger('tg-mtproto-proxy')
|
||||
MAX_PACKET = 4 * 1024 * 1024
|
||||
MAX_PLAIN_PADDING = 256
|
||||
MAX_CHANNEL_REQUESTS = 8
|
||||
MAX_CHANNEL_BYTES = 8 * 1024 * 1024
|
||||
MAX_LANE_REQUESTS = 64
|
||||
@@ -97,10 +98,11 @@ class _ReplayPacket:
|
||||
pending: Optional[asyncio.Task] = None
|
||||
originals: Set[asyncio.Task] = field(default_factory=set, repr=False)
|
||||
retired: bool = False
|
||||
reply_id: Optional[bytes] = None
|
||||
|
||||
@property
|
||||
def replayed(self) -> bool:
|
||||
return self.attempts > 0
|
||||
return self.last_attempt_at > 0 or self.attempts > 0
|
||||
|
||||
|
||||
async def _read_packet(reader, decryptor, tag: bytes) -> Tuple[bytes, bool]:
|
||||
@@ -116,16 +118,20 @@ async def _read_packet(reader, decryptor, tag: bytes) -> Tuple[bytes, bool]:
|
||||
else:
|
||||
value, = struct.unpack('<I', await plaintext(4))
|
||||
quick, length = bool(value & 0x80000000), value & 0x7fffffff
|
||||
if not 24 <= length <= MAX_PACKET + (15 if tag == PROTO_TAG_SECURE else 0):
|
||||
if not 24 <= length <= MAX_PACKET + (MAX_PLAIN_PADDING if tag == PROTO_TAG_SECURE else 0):
|
||||
raise ValueError('native packet length outside supported range: %d' % length)
|
||||
body = await plaintext(length)
|
||||
if tag == PROTO_TAG_SECURE:
|
||||
if body[:8] == b'\x00' * 8:
|
||||
packet_length = 20 + int.from_bytes(body[16:20], 'little')
|
||||
max_padding = MAX_PLAIN_PADDING
|
||||
else:
|
||||
packet_length = 24 + ((length - 24) // 16) * 16
|
||||
if not 24 <= packet_length <= length or length - packet_length > 15:
|
||||
raise ValueError('invalid padded native packet')
|
||||
max_padding = 15
|
||||
padding = length - packet_length
|
||||
if not 24 <= packet_length <= length or padding > max_padding:
|
||||
raise ValueError('invalid padded native packet: frame=%d packet=%d padding=%d limit=%d' % (
|
||||
length, packet_length, padding, max_padding))
|
||||
body = body[:packet_length]
|
||||
if len(body) % 4 or len(body) > MAX_PACKET:
|
||||
raise ValueError('invalid native packet alignment or size')
|
||||
@@ -461,6 +467,9 @@ class _HttpChannel:
|
||||
reply = await self.lane._post(body, self.channel_id, replay=replay, channel=self)
|
||||
self.last_progress = time.monotonic()
|
||||
if reply and not self.closed:
|
||||
if packet is not None and reply[:24] != packet.reply_id:
|
||||
packet.reply_id = reply[:24]
|
||||
packet.attempts = 0
|
||||
if self.queue.full() or self.reply_bytes + len(reply) > MAX_CHANNEL_BYTES:
|
||||
raise BufferError('H2 client response queue exceeded bound')
|
||||
self.reply_bytes += len(reply)
|
||||
@@ -525,10 +534,14 @@ class _HttpChannel:
|
||||
|
||||
async def _recover(self) -> None:
|
||||
while not self.closed:
|
||||
wakeup = asyncio.create_task(self.recovery_wakeup.wait())
|
||||
try:
|
||||
await asyncio.wait_for(self.recovery_wakeup.wait(), REPLAY_CHECK_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
# wait_for can swallow cancellation when the wakeup completes
|
||||
# at the same time on Python 3.8-3.11 (CPython #86296).
|
||||
await asyncio.wait((wakeup,), timeout=REPLAY_CHECK_SECONDS)
|
||||
finally:
|
||||
wakeup.cancel()
|
||||
await asyncio.gather(wakeup, return_exceptions=True)
|
||||
self.recovery_wakeup.clear()
|
||||
now = time.monotonic()
|
||||
self.lane.log_wait(now)
|
||||
@@ -616,7 +629,7 @@ class _HttpLane:
|
||||
self.reply_buffer_bytes = 0
|
||||
self.capacity = asyncio.Condition()
|
||||
self.client = httpx.AsyncClient(
|
||||
transport=H2Transport(create_ssl_context(), max_streams=MAX_LANE_REQUESTS),
|
||||
transport=H2Transport(max_streams=MAX_LANE_REQUESTS),
|
||||
trust_env=False,
|
||||
timeout=httpx.Timeout(REQUEST_TIMEOUT, connect=8, pool=REQUEST_TIMEOUT),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import signal
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
|
||||
|
||||
log = logging.getLogger('tg-mtproto-proxy')
|
||||
|
||||
|
||||
def _replace_self_pipe(loop):
|
||||
reader, writer = socket.socketpair()
|
||||
try:
|
||||
reader.setblocking(False)
|
||||
writer.setblocking(False)
|
||||
if threading.current_thread() is threading.main_thread():
|
||||
previous_fd = signal.set_wakeup_fd(writer.fileno())
|
||||
if previous_fd != loop._csock.fileno():
|
||||
signal.set_wakeup_fd(previous_fd)
|
||||
except BaseException:
|
||||
reader.close()
|
||||
writer.close()
|
||||
raise
|
||||
|
||||
old_reader, old_writer = loop._ssock, loop._csock
|
||||
loop._ssock, loop._csock = reader, writer
|
||||
old_reader.close()
|
||||
old_writer.close()
|
||||
|
||||
|
||||
class _SelfPipeGuard:
|
||||
"""Work around https://github.com/python/cpython/issues/156333"""
|
||||
|
||||
def __init__(self, loop):
|
||||
self.loop = loop
|
||||
self.read_self_pipe = loop._loop_self_reading
|
||||
|
||||
def __call__(self, future=None):
|
||||
loop = self.loop
|
||||
if future is not None:
|
||||
if loop._self_reading_future is not future:
|
||||
return
|
||||
try:
|
||||
data = future.result()
|
||||
except OSError as exc:
|
||||
reason = str(exc)
|
||||
except BaseException:
|
||||
return self.read_self_pipe(future)
|
||||
else:
|
||||
if data:
|
||||
return self.read_self_pipe(future)
|
||||
reason = 'EOF'
|
||||
|
||||
loop._self_reading_future = None
|
||||
self._recover(reason)
|
||||
return
|
||||
return self.read_self_pipe(future)
|
||||
|
||||
def _recover(self, reason):
|
||||
loop = self.loop
|
||||
if loop.is_closed() or loop._self_reading_future is not None:
|
||||
return
|
||||
try:
|
||||
_replace_self_pipe(loop)
|
||||
except OSError as exc:
|
||||
log.warning('Windows event loop wakeup socket recovery failed: %s; '
|
||||
'retry in 1s', exc)
|
||||
loop.call_later(1, self._recover, reason)
|
||||
return
|
||||
log.warning('Windows event loop wakeup socket lost (%s); recreated', reason)
|
||||
self.read_self_pipe()
|
||||
|
||||
|
||||
def install_self_pipe_guard(loop):
|
||||
if sys.platform != 'win32' or not isinstance(loop, asyncio.ProactorEventLoop):
|
||||
return
|
||||
if not isinstance(loop._loop_self_reading, _SelfPipeGuard):
|
||||
guard = _SelfPipeGuard(loop)
|
||||
loop._loop_self_reading = guard
|
||||
future = loop._self_reading_future
|
||||
if future is not None:
|
||||
future.remove_done_callback(guard.read_self_pipe)
|
||||
future.add_done_callback(guard)
|
||||
+46
-12
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import time
|
||||
from collections import deque
|
||||
from functools import lru_cache
|
||||
|
||||
import httpx
|
||||
from h2.config import H2Configuration
|
||||
@@ -15,8 +16,18 @@ from h2.events import (
|
||||
from h2.exceptions import H2Error, StreamClosedError
|
||||
from h2.settings import SettingCodes, Settings
|
||||
|
||||
from .utils import create_ssl_context
|
||||
|
||||
STREAM_RECEIVE_WINDOW = 256 * 1024
|
||||
CONNECTION_RECEIVE_WINDOW = 4 * 1024 * 1024
|
||||
IO_BATCH_SIZE = 64 * 1024
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _default_ssl_context():
|
||||
context = create_ssl_context()
|
||||
context.set_alpn_protocols(['h2'])
|
||||
return context
|
||||
|
||||
|
||||
class _ResponseStream(httpx.AsyncByteStream):
|
||||
@@ -26,6 +37,7 @@ class _ResponseStream(httpx.AsyncByteStream):
|
||||
self.read_timeout = read_timeout
|
||||
self.changed = asyncio.Event()
|
||||
self.chunks = deque()
|
||||
self.consumed_credit = 0
|
||||
self.headers = None
|
||||
self.response_started = None
|
||||
self.error = None
|
||||
@@ -37,7 +49,14 @@ class _ResponseStream(httpx.AsyncByteStream):
|
||||
try:
|
||||
await asyncio.wait_for(self.changed.wait(), self.read_timeout)
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise httpx.ReadTimeout('Timed out waiting for H2 stream %d' % self.stream_id) from exc
|
||||
error = httpx.ReadTimeout('Timed out waiting for H2 stream %d' % self.stream_id)
|
||||
# An idle socket can remain established after the path stops
|
||||
# delivering data. Reconnect once its last active request times
|
||||
# out, while preserving unrelated requests and buffered replies.
|
||||
if not any(stream is not self and not stream.ended
|
||||
for stream in self.connection.streams.values()):
|
||||
self.connection.fail(error)
|
||||
raise error from exc
|
||||
|
||||
def check_error(self):
|
||||
if self.error is not None:
|
||||
@@ -90,6 +109,8 @@ class _Connection:
|
||||
data = self.h2.data_to_send()
|
||||
if data:
|
||||
self.writer.write(data)
|
||||
return True
|
||||
return False
|
||||
|
||||
async def drain(self):
|
||||
try:
|
||||
@@ -117,11 +138,12 @@ class _Connection:
|
||||
async def read_loop(self):
|
||||
try:
|
||||
while self.error is None:
|
||||
data = await self.reader.read(64 * 1024)
|
||||
data = await self.reader.read(IO_BATCH_SIZE)
|
||||
if not data:
|
||||
raise httpx.ReadError('H2 peer closed the connection')
|
||||
events = self.h2.receive_data(data)
|
||||
terminated = any(isinstance(e, ConnectionTerminated) for e in events)
|
||||
connection_credit = 0
|
||||
for event in events:
|
||||
if isinstance(event, RemoteSettingsChanged):
|
||||
self.settings_received = True
|
||||
@@ -135,7 +157,7 @@ class _Connection:
|
||||
stream = self.streams.get(getattr(event, 'stream_id', None))
|
||||
if isinstance(event, DataReceived):
|
||||
if event.flow_controlled_length and not terminated:
|
||||
self.h2.increment_flow_control_window(event.flow_controlled_length)
|
||||
connection_credit += event.flow_controlled_length
|
||||
if stream is not None:
|
||||
if event.data:
|
||||
stream.chunks.append((event.data, event.flow_controlled_length))
|
||||
@@ -154,8 +176,10 @@ class _Connection:
|
||||
self.changed.set()
|
||||
if stream is not None:
|
||||
stream.changed.set()
|
||||
self.flush()
|
||||
await self.drain()
|
||||
if connection_credit:
|
||||
self.h2.increment_flow_control_window(connection_credit)
|
||||
if self.flush():
|
||||
await self.drain()
|
||||
except asyncio.CancelledError:
|
||||
self.fail(httpx.ReadError('H2 reader stopped'))
|
||||
raise
|
||||
@@ -192,7 +216,7 @@ class _Connection:
|
||||
self.changed.clear()
|
||||
self.check_error()
|
||||
stream.check_error()
|
||||
count = min(len(data) - offset, self.h2.max_outbound_frame_size,
|
||||
count = min(len(data) - offset, IO_BATCH_SIZE,
|
||||
self.h2.local_flow_control_window(stream.stream_id))
|
||||
if count <= 0:
|
||||
try:
|
||||
@@ -200,8 +224,11 @@ class _Connection:
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise httpx.WriteTimeout('H2 upload flow control timed out') from exc
|
||||
continue
|
||||
self.h2.send_data(stream.stream_id, data[offset:offset + count])
|
||||
offset += count
|
||||
batch_end = offset + count
|
||||
while offset < batch_end:
|
||||
frame_end = min(batch_end, offset + self.h2.max_outbound_frame_size)
|
||||
self.h2.send_data(stream.stream_id, data[offset:frame_end])
|
||||
offset = frame_end
|
||||
self.flush()
|
||||
await self.drain()
|
||||
self.h2.end_stream(stream.stream_id)
|
||||
@@ -249,8 +276,12 @@ class _Connection:
|
||||
|
||||
def consumed(self, stream, credit):
|
||||
if credit and not stream.ended and not stream.closed and self.error is None:
|
||||
stream.consumed_credit += credit
|
||||
if stream.chunks and stream.consumed_credit < IO_BATCH_SIZE:
|
||||
return
|
||||
try:
|
||||
self.h2.increment_flow_control_window(credit, stream.stream_id)
|
||||
self.h2.increment_flow_control_window(stream.consumed_credit, stream.stream_id)
|
||||
stream.consumed_credit = 0
|
||||
self.flush()
|
||||
except StreamClosedError:
|
||||
pass
|
||||
@@ -278,9 +309,12 @@ class _Connection:
|
||||
|
||||
|
||||
class H2Transport(httpx.AsyncBaseTransport):
|
||||
def __init__(self, ssl_context, max_streams=64):
|
||||
self.ssl_context = ssl_context
|
||||
self.ssl_context.set_alpn_protocols(['h2'])
|
||||
def __init__(self, ssl_context=None, max_streams=64):
|
||||
if ssl_context is None:
|
||||
self.ssl_context = _default_ssl_context()
|
||||
else:
|
||||
self.ssl_context = ssl_context
|
||||
self.ssl_context.set_alpn_protocols(['h2'])
|
||||
self.max_streams = max_streams
|
||||
self.connection = None
|
||||
self.origin = None
|
||||
|
||||
+23
-5
@@ -5,7 +5,7 @@ import time
|
||||
|
||||
from collections import deque
|
||||
from urllib.parse import urlencode
|
||||
from typing import Dict, List, Optional, Tuple, Set
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from .raw_websocket import RawWebSocket, WsHandshakeError
|
||||
from .stats import stats
|
||||
@@ -237,7 +237,7 @@ class _CfWorkerPool:
|
||||
|
||||
def __init__(self):
|
||||
self._idle: Dict[int, deque] = {}
|
||||
self._refilling: Set[int] = set()
|
||||
self._refilling: Dict[int, asyncio.Task] = {}
|
||||
self._exhausted_until: Dict[str, float] = {}
|
||||
|
||||
async def get(self, dc: int, fallback_dst: str,
|
||||
@@ -269,8 +269,7 @@ class _CfWorkerPool:
|
||||
def _schedule_refill(self, dc, fallback_dst, worker_domains):
|
||||
if dc in self._refilling:
|
||||
return
|
||||
self._refilling.add(dc)
|
||||
asyncio.create_task(self._refill(
|
||||
self._refilling[dc] = asyncio.create_task(self._refill(
|
||||
dc, fallback_dst, list(worker_domains)))
|
||||
|
||||
async def _refill(self, dc, fallback_dst, worker_domains):
|
||||
@@ -287,11 +286,15 @@ class _CfWorkerPool:
|
||||
if connected is None:
|
||||
break
|
||||
ws, worker_domain = connected
|
||||
if self._refilling.get(dc) is not asyncio.current_task():
|
||||
await self._quiet_close(ws)
|
||||
return
|
||||
bucket.append((ws, time.monotonic(), worker_domain))
|
||||
log.debug("CF worker pool refilled DC%d: %d ready",
|
||||
dc, len(bucket))
|
||||
finally:
|
||||
self._refilling.discard(dc)
|
||||
if self._refilling.get(dc) is asyncio.current_task():
|
||||
self._refilling.pop(dc, None)
|
||||
|
||||
async def _connect_one(self, worker_domains, fallback_dst, dc):
|
||||
query = urlencode({
|
||||
@@ -359,10 +362,25 @@ class _CfWorkerPool:
|
||||
log.info("CF worker pool warmup started for %d DC(s)", len(cf_fallbacks))
|
||||
|
||||
def reset(self):
|
||||
loop = asyncio.get_running_loop()
|
||||
for task in self._refilling.values():
|
||||
if not task.done() and task.get_loop() is loop:
|
||||
task.cancel()
|
||||
for bucket in self._idle.values():
|
||||
for ws, _, _ in bucket:
|
||||
try:
|
||||
ws.writer.close()
|
||||
except Exception as exc:
|
||||
log.debug("CF worker pool close failed: %r", exc)
|
||||
self._idle.clear()
|
||||
self._refilling.clear()
|
||||
self._exhausted_until.clear()
|
||||
|
||||
async def close(self):
|
||||
tasks = list(self._refilling.values())
|
||||
self.reset()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
|
||||
ws_pool = _WsPool()
|
||||
cf_worker_pool = _CfWorkerPool()
|
||||
@@ -21,6 +21,8 @@ _st_BBQ4s = struct.Struct('>BBQ4s')
|
||||
_st_H = struct.Struct('>H')
|
||||
_st_Q = struct.Struct('>Q')
|
||||
|
||||
_XOR_TABLES = tuple(bytes(value ^ key for value in range(256)) for key in range(256))
|
||||
|
||||
_ssl_ctx = create_ssl_context()
|
||||
_ssl_ctx_fronting = create_ssl_context(check_hostname=False)
|
||||
|
||||
@@ -42,6 +44,11 @@ def _xor_mask(data: bytes, mask: bytes) -> bytes:
|
||||
if not data:
|
||||
return data
|
||||
n = len(data)
|
||||
if n >= 512:
|
||||
masked = bytearray(data)
|
||||
for offset in range(4):
|
||||
masked[offset::4] = masked[offset::4].translate(_XOR_TABLES[mask[offset]])
|
||||
return bytes(masked)
|
||||
mask_rep = (mask * (n // 4 + 1))[:n]
|
||||
return (int.from_bytes(data, 'big') ^
|
||||
int.from_bytes(mask_rep, 'big')).to_bytes(n, 'big')
|
||||
|
||||
@@ -29,6 +29,7 @@ from .balancer import balancer
|
||||
from .pool import ws_pool, cf_worker_pool
|
||||
from .cf_h2 import CfH2Pool
|
||||
from .network_debug import log_ws_flow
|
||||
from .event_loop import install_self_pipe_guard
|
||||
from ._aes import Cipher, algorithms, modes
|
||||
|
||||
|
||||
@@ -299,7 +300,7 @@ async def _handle_client(reader, writer, secret: bytes):
|
||||
label, dc, media_tag)
|
||||
splitter_fb = None
|
||||
try:
|
||||
splitter_fb = MsgSplitter(relay_init, proto_int)
|
||||
splitter_fb = MsgSplitter(proto_int)
|
||||
except Exception:
|
||||
pass
|
||||
ok = await do_fallback(
|
||||
@@ -317,7 +318,7 @@ async def _handle_client(reader, writer, secret: bytes):
|
||||
|
||||
splitter = None
|
||||
try:
|
||||
splitter = MsgSplitter(relay_init, proto_int)
|
||||
splitter = MsgSplitter(proto_int)
|
||||
log.debug("[%s] MsgSplitter activated for proto 0x%08X",
|
||||
label, proto_int)
|
||||
except Exception:
|
||||
@@ -360,6 +361,7 @@ _client_tasks: Set[asyncio.Task] = set()
|
||||
|
||||
async def _run(stop_event: Optional[asyncio.Event] = None):
|
||||
global _server_instance, _server_stop_event, cf_h2_pool
|
||||
install_self_pipe_guard(asyncio.get_running_loop())
|
||||
_server_stop_event = stop_event
|
||||
|
||||
reset_tcp_backoff()
|
||||
@@ -534,6 +536,7 @@ async def _run(stop_event: Optional[asyncio.Event] = None):
|
||||
await _quiet_cancel(task)
|
||||
await server.wait_closed()
|
||||
await ws_pool.close()
|
||||
await cf_worker_pool.close()
|
||||
if cf_h2_pool is not None:
|
||||
await cf_h2_pool.close()
|
||||
cf_h2_pool = None
|
||||
|
||||
+8
-2
@@ -3,7 +3,9 @@ import urllib.request
|
||||
import http.client
|
||||
import ssl
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
import certifi
|
||||
|
||||
@@ -31,7 +33,8 @@ PROTO_PADDED_INTERMEDIATE_INT = 0xDDDDDDDD
|
||||
RESERVED_FIRST_BYTES = {0xEF}
|
||||
RESERVED_STARTS = {b'\x48\x45\x41\x44', b'\x50\x4F\x53\x54',
|
||||
b'\x47\x45\x54\x20', b'\xee\xee\xee\xee',
|
||||
b'\xdd\xdd\xdd\xdd', b'\x16\x03\x01\x02'}
|
||||
b'\xdd\xdd\xdd\xdd', b'\x16\x03\x01\x02',
|
||||
b'\x4F\x50\x54\x49'}
|
||||
RESERVED_CONTINUE = b'\x00\x00\x00\x00'
|
||||
|
||||
_GITHUB_IPS: Dict[str, str] = {
|
||||
@@ -141,7 +144,10 @@ class _PinnedHTTPSHandler(urllib.request.HTTPSHandler):
|
||||
|
||||
|
||||
def create_ssl_context(*, check_hostname: bool = True) -> ssl.SSLContext:
|
||||
context = ssl.create_default_context(cafile=certifi.where())
|
||||
bundle_dir = getattr(sys, '_MEIPASS', None)
|
||||
cafile = (os.path.join(bundle_dir, 'certifi', 'cacert.pem')
|
||||
if bundle_dir else certifi.where())
|
||||
context = ssl.create_default_context(cafile=cafile)
|
||||
context.load_default_certs()
|
||||
context.check_hostname = check_hostname
|
||||
return context
|
||||
|
||||
+66
-12
@@ -3,6 +3,7 @@ import asyncio
|
||||
import time
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
from proxy._aes import Cipher, algorithms, modes
|
||||
from proxy.bridge import MsgSplitter, bridge_ws_reencrypt
|
||||
@@ -40,9 +41,10 @@ def _intermediate(payload: bytes) -> bytes:
|
||||
class MsgSplitterTest(unittest.TestCase):
|
||||
def _split(self, proto_int, packets, chunk_sizes=None):
|
||||
relay_init = _relay_init()
|
||||
splitter = MsgSplitter(relay_init, proto_int)
|
||||
splitter = MsgSplitter(proto_int)
|
||||
enc = _encryptor(relay_init)
|
||||
stream = enc.update(b''.join(packets))
|
||||
plain = b''.join(packets)
|
||||
stream = enc.update(plain)
|
||||
|
||||
chunks = []
|
||||
if chunk_sizes is None:
|
||||
@@ -56,8 +58,10 @@ class MsgSplitterTest(unittest.TestCase):
|
||||
chunks.append(stream[offset:])
|
||||
|
||||
parts = []
|
||||
offset = 0
|
||||
for chunk in chunks:
|
||||
parts.extend(splitter.split(chunk))
|
||||
parts.extend(splitter.split(chunk, plain[offset:offset + len(chunk)]))
|
||||
offset += len(chunk)
|
||||
return splitter, stream, parts
|
||||
|
||||
def test_abridged_stream_splits_into_packets(self):
|
||||
@@ -94,27 +98,77 @@ class MsgSplitterTest(unittest.TestCase):
|
||||
self.assertEqual(len(parts), 8)
|
||||
|
||||
def test_empty_chunk_yields_nothing(self):
|
||||
splitter = MsgSplitter(_relay_init(), PROTO_INTERMEDIATE_INT)
|
||||
self.assertEqual(splitter.split(b''), [])
|
||||
splitter = MsgSplitter(PROTO_INTERMEDIATE_INT)
|
||||
self.assertEqual(splitter.split(b'', b''), [])
|
||||
|
||||
def test_zero_length_packet_disables_splitting(self):
|
||||
relay_init = _relay_init()
|
||||
splitter = MsgSplitter(relay_init, PROTO_INTERMEDIATE_INT)
|
||||
splitter = MsgSplitter(PROTO_INTERMEDIATE_INT)
|
||||
enc = _encryptor(relay_init)
|
||||
stream = enc.update((0).to_bytes(4, 'little') + b'tail')
|
||||
parts = splitter.split(stream)
|
||||
plain = (0).to_bytes(4, 'little') + b'tail'
|
||||
stream = enc.update(plain)
|
||||
parts = splitter.split(stream, plain)
|
||||
self.assertEqual(parts, [stream])
|
||||
self.assertEqual(splitter.split(b'raw'), [b'raw'])
|
||||
self.assertEqual(splitter.split(b'raw', b'raw'), [b'raw'])
|
||||
|
||||
def test_flush_returns_buffered_tail_once(self):
|
||||
relay_init = _relay_init()
|
||||
splitter = MsgSplitter(relay_init, PROTO_INTERMEDIATE_INT)
|
||||
splitter = MsgSplitter(PROTO_INTERMEDIATE_INT)
|
||||
enc = _encryptor(relay_init)
|
||||
partial = enc.update(_intermediate(b'x' * 32)[:10])
|
||||
self.assertEqual(splitter.split(partial), [])
|
||||
plain = _intermediate(b'x' * 32)[:10]
|
||||
partial = enc.update(plain)
|
||||
self.assertEqual(splitter.split(partial, plain), [])
|
||||
self.assertEqual(splitter.flush(), [partial])
|
||||
self.assertEqual(splitter.flush(), [])
|
||||
|
||||
def test_extended_and_quick_ack_headers_can_cross_chunk_boundaries(self):
|
||||
for proto, frame in ((PROTO_ABRIDGED_INT, _abridged),
|
||||
(PROTO_INTERMEDIATE_INT, _intermediate),
|
||||
(PROTO_PADDED_INTERMEDIATE_INT, _intermediate)):
|
||||
for size in (16, 512, 65536):
|
||||
packet = bytearray(frame(b'p' * size))
|
||||
packet[0 if proto == PROTO_ABRIDGED_INT else 3] |= 0x80
|
||||
packets = [bytes(packet), frame(b't' * 8)]
|
||||
for header_split in (1, 2, 3):
|
||||
with self.subTest(proto=proto, size=size, header_split=header_split):
|
||||
_, stream, parts = self._split(
|
||||
proto, packets, chunk_sizes=[header_split, 1, 11, size - 8])
|
||||
self.assertEqual(parts, [stream[:len(packet)], stream[len(packet):]])
|
||||
|
||||
|
||||
class WsReencryptTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_relay_preserves_encrypted_packets_and_incomplete_tail(self):
|
||||
for proto, frame in ((PROTO_ABRIDGED_INT, _abridged),
|
||||
(PROTO_INTERMEDIATE_INT, _intermediate),
|
||||
(PROTO_PADDED_INTERMEDIATE_INT, _intermediate)):
|
||||
with self.subTest(proto=proto):
|
||||
packets = [frame(b'a' * 8), frame(b'b' * 16),
|
||||
frame(b'c' * 70000), frame(b'd' * 32)[:7]]
|
||||
client_init, relay_init = _relay_init(), _relay_init()
|
||||
reader = asyncio.StreamReader()
|
||||
reader.feed_data(_encryptor(client_init).update(b''.join(packets)))
|
||||
reader.feed_eof()
|
||||
sent = []
|
||||
|
||||
async def send(data):
|
||||
sent.append(data)
|
||||
|
||||
async def send_batch(parts):
|
||||
sent.extend(parts)
|
||||
|
||||
async def recv():
|
||||
await asyncio.Future()
|
||||
|
||||
ws = SimpleNamespace(send=send, send_batch=send_batch, recv=recv,
|
||||
close=AsyncMock())
|
||||
writer = Mock(wait_closed=AsyncMock())
|
||||
ctx = SimpleNamespace(clt_dec=_encryptor(client_init), tg_enc=_encryptor(relay_init))
|
||||
await asyncio.wait_for(bridge_ws_reencrypt(
|
||||
reader, writer, ws, 'framing-test', ctx, splitter=MsgSplitter(proto)), 1)
|
||||
decoder = _encryptor(relay_init)
|
||||
self.assertEqual([decoder.update(part) for part in sent], packets)
|
||||
ws.close.assert_awaited_once()
|
||||
|
||||
|
||||
class WsDiagnosticsTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_flow_distinguishes_upstream_send_from_native_backpressure(self):
|
||||
|
||||
@@ -56,6 +56,47 @@ class NativeFramingTest(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(decoded, body, padding)
|
||||
self.assertFalse(quick)
|
||||
|
||||
async def test_plaintext_accepts_extended_padding_used_by_apple_and_android(self):
|
||||
body = b'\x00' * 8 + b'm' * 8 + struct.pack('<I', 20) + b'p' * 20
|
||||
# MTProtoKit appends 0..236 bytes outside message_data_length, then
|
||||
# the TCP transport adds 0..15 more. Android uses 0..256 directly.
|
||||
for padding in range(257):
|
||||
with self.subTest(padding=padding):
|
||||
wire = body + b'z' * padding
|
||||
reader = asyncio.StreamReader()
|
||||
reader.feed_data(struct.pack('<I', len(wire) | 0x80000000) + wire)
|
||||
decoded, quick = await _read_packet(reader, _IdentityCipher(), PROTO_TAG_SECURE)
|
||||
self.assertEqual(decoded, body)
|
||||
self.assertTrue(quick)
|
||||
|
||||
async def test_padded_plaintext_preserves_padding_included_in_message_length(self):
|
||||
body = b'\x00' * 8 + b'm' * 8 + struct.pack('<I', 272) + b'p' * 20 + b'd' * 252
|
||||
decoded, _ = await self._packet(struct.pack('<I', len(body) + 15) + body + b'z' * 15,
|
||||
PROTO_TAG_SECURE)
|
||||
self.assertEqual(decoded, body)
|
||||
|
||||
async def test_extended_padding_preserves_packet_size_limit(self):
|
||||
body = b'\x00' * 8 + b'm' * 8 + struct.pack('<I', 20) + b'p' * 20
|
||||
wire = body + b'z' * 256
|
||||
with patch('proxy.cf_h2.MAX_PACKET', len(body)):
|
||||
decoded, _ = await self._packet(struct.pack('<I', len(wire)) + wire, PROTO_TAG_SECURE)
|
||||
self.assertEqual(decoded, body)
|
||||
oversized = body[:16] + struct.pack('<I', 24) + b'p' * 24
|
||||
with self.assertRaisesRegex(ValueError, 'size'):
|
||||
await self._packet(struct.pack('<I', len(oversized)) + oversized, PROTO_TAG_SECURE)
|
||||
encrypted = b'k' * 8 + b'm' * 16 + b'c' * 32
|
||||
with self.assertRaisesRegex(ValueError, 'size'):
|
||||
await self._packet(struct.pack('<I', len(encrypted)) + encrypted, PROTO_TAG_SECURE)
|
||||
|
||||
async def test_invalid_plaintext_lengths_and_excess_padding_are_rejected(self):
|
||||
for declared, payload, padding in ((0, 20, 0), (24, 20, 0), (21, 21, 0),
|
||||
(20, 20, 257), (0xffffffff, 20, 0)):
|
||||
with self.subTest(declared=declared, padding=padding):
|
||||
body = b'\x00' * 8 + b'm' * 8 + struct.pack('<I', declared) + b'p' * payload
|
||||
wire = body + b'z' * padding
|
||||
with self.assertRaises(ValueError):
|
||||
await self._packet(struct.pack('<I', len(wire)) + wire, PROTO_TAG_SECURE)
|
||||
|
||||
async def test_padded_ciphertext_removes_full_padding_blocks(self):
|
||||
body = b'k' * 8 + b'm' * 16 + b'c' * 32
|
||||
for padding in range(16):
|
||||
@@ -580,6 +621,29 @@ class HttpMultiplexTest(unittest.IsolatedAsyncioTestCase):
|
||||
await healthy.send(b'h' * 40, False)
|
||||
self.assertEqual(await asyncio.wait_for(healthy.receive(), 1), b'r' * 65536)
|
||||
|
||||
async def test_recovery_cancellation_is_not_lost_when_wakeup_completes(self):
|
||||
channel = self._channel()
|
||||
wait = channel.recovery_wakeup.wait
|
||||
calls = 0
|
||||
|
||||
async def wake_and_cancel():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
recovery.cancel()
|
||||
return True
|
||||
return await wait()
|
||||
|
||||
with patch.object(channel.recovery_wakeup, 'wait', side_effect=wake_and_cancel):
|
||||
recovery = asyncio.create_task(channel._recover())
|
||||
try:
|
||||
done, _ = await asyncio.wait((recovery,), timeout=.2)
|
||||
self.assertIn(recovery, done)
|
||||
self.assertTrue(recovery.cancelled())
|
||||
finally:
|
||||
recovery.cancel()
|
||||
await asyncio.gather(recovery, return_exceptions=True)
|
||||
|
||||
async def test_slow_receiver_queue_overflow_does_not_break_other_channels(self):
|
||||
async def handler(request):
|
||||
return httpx.Response(200, content=request.content, extensions={'http_version': b'HTTP/2'})
|
||||
@@ -756,6 +820,68 @@ class HttpMultiplexTest(unittest.IsolatedAsyncioTestCase):
|
||||
recovery.cancel()
|
||||
await asyncio.gather(recovery, return_exceptions=True)
|
||||
|
||||
async def test_new_replies_refresh_idle_receiver_budget_without_native_upload(self):
|
||||
replies = [b'samekey!' + bytes([index]) * 32 for index in range(6)]
|
||||
calls = []
|
||||
exhausted = asyncio.Event()
|
||||
|
||||
def handler(request):
|
||||
calls.append(request.content)
|
||||
count = len(calls)
|
||||
if count == len(replies) + 3:
|
||||
exhausted.set()
|
||||
return httpx.Response(200, content=replies[count - 1] if count <= len(replies) else b'',
|
||||
extensions={'http_version': b'HTTP/2'})
|
||||
|
||||
self._transport(handler)
|
||||
channel = self._channel()
|
||||
body = b'samekey!' + b'a' * 32
|
||||
with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .005), \
|
||||
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .002), \
|
||||
patch('proxy.cf_h2.REPLAY_RETRY_SECONDS', .01):
|
||||
await channel.send(body, False)
|
||||
recovery = asyncio.create_task(channel._recover())
|
||||
try:
|
||||
# The native client is waiting for a batch of replies and
|
||||
# sends no new packet between them. Three successful polls
|
||||
# must not use up the budget while more responses arrive.
|
||||
for reply in replies:
|
||||
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), reply)
|
||||
await asyncio.wait_for(exhausted.wait(), 1)
|
||||
await asyncio.sleep(.05)
|
||||
self.assertEqual(calls, [body] * (len(replies) + 3))
|
||||
self.assertFalse(channel.pending)
|
||||
self.assertFalse(channel.replay_pending)
|
||||
finally:
|
||||
recovery.cancel()
|
||||
await asyncio.gather(recovery, return_exceptions=True)
|
||||
|
||||
async def test_duplicate_reply_does_not_refresh_idle_receiver_budget(self):
|
||||
calls = []
|
||||
reply = b'r' * 40
|
||||
|
||||
def handler(request):
|
||||
calls.append(request.content)
|
||||
return httpx.Response(200, content=reply, extensions={'http_version': b'HTTP/2'})
|
||||
|
||||
self._transport(handler)
|
||||
channel = self._channel()
|
||||
with patch('proxy.cf_h2.REPLAY_IDLE_SECONDS', .005), \
|
||||
patch('proxy.cf_h2.REPLAY_CHECK_SECONDS', .002), \
|
||||
patch('proxy.cf_h2.REPLAY_RETRY_SECONDS', .01):
|
||||
await channel.send(b'samekey!' + b'a' * 32, False)
|
||||
recovery = asyncio.create_task(channel._recover())
|
||||
try:
|
||||
for _ in range(4):
|
||||
self.assertEqual(await asyncio.wait_for(channel.receive(), 1), reply)
|
||||
await asyncio.sleep(.05)
|
||||
self.assertEqual(len(calls), 4)
|
||||
self.assertFalse(channel.pending)
|
||||
self.assertFalse(channel.replay_pending)
|
||||
finally:
|
||||
recovery.cancel()
|
||||
await asyncio.gather(recovery, return_exceptions=True)
|
||||
|
||||
async def test_old_pending_request_has_bounded_retries_after_newer_requests(self):
|
||||
body = b'samekey!' + b't' * 32
|
||||
original_started = asyncio.Event()
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import asyncio
|
||||
import signal
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from proxy import event_loop
|
||||
|
||||
|
||||
async def _wait_for_replacement(loop, old_writer, timeout=1):
|
||||
async def wait():
|
||||
while loop._csock is old_writer:
|
||||
await asyncio.sleep(.005)
|
||||
await asyncio.wait_for(wait(), timeout)
|
||||
|
||||
|
||||
def _wake_later(loop, callback):
|
||||
def send():
|
||||
time.sleep(.03)
|
||||
loop.call_soon_threadsafe(callback)
|
||||
thread = threading.Thread(target=send, daemon=True)
|
||||
thread.start()
|
||||
return thread
|
||||
|
||||
|
||||
@unittest.skipUnless(sys.platform == 'win32', 'Windows proactor self-pipe')
|
||||
class WindowsSelfPipeTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.loop = asyncio.ProactorEventLoop()
|
||||
self.addCleanup(self.loop.close)
|
||||
|
||||
def test_eof_replaces_sockets_and_stops_idle_callbacks(self):
|
||||
loop = self.loop
|
||||
read = Mock(wraps=loop._loop_self_reading)
|
||||
loop._loop_self_reading = read
|
||||
|
||||
async def run():
|
||||
# Install after the original callback has armed a read, as can
|
||||
# happen when the proxy starts in an already running event loop.
|
||||
await asyncio.sleep(.01)
|
||||
event_loop.install_self_pipe_guard(loop)
|
||||
callback = loop._loop_self_reading
|
||||
event_loop.install_self_pipe_guard(loop)
|
||||
self.assertIs(loop._loop_self_reading, callback)
|
||||
old_reader, old_writer = loop._ssock, loop._csock
|
||||
internal_fds = loop._internal_fds
|
||||
with patch.object(event_loop.socket, 'socketpair', wraps=socket.socketpair) as pair:
|
||||
old_writer.shutdown(socket.SHUT_WR)
|
||||
await _wait_for_replacement(loop, old_writer)
|
||||
self.assertEqual(pair.call_count, 1)
|
||||
self.assertEqual(old_reader.fileno(), -1)
|
||||
self.assertEqual(old_writer.fileno(), -1)
|
||||
self.assertEqual(loop._internal_fds, internal_fds)
|
||||
self.assertFalse(loop._self_reading_future.done())
|
||||
read.reset_mock()
|
||||
await asyncio.sleep(.05)
|
||||
read.assert_not_called()
|
||||
|
||||
completed = loop.create_future()
|
||||
thread = _wake_later(loop, lambda: completed.set_result(True))
|
||||
started = time.monotonic()
|
||||
try:
|
||||
self.assertTrue(await asyncio.wait_for(completed, 1))
|
||||
self.assertLess(time.monotonic() - started, .5)
|
||||
finally:
|
||||
thread.join(timeout=1)
|
||||
|
||||
with self.assertLogs(event_loop.log, 'WARNING') as messages:
|
||||
loop.run_until_complete(run())
|
||||
self.assertEqual(len(messages.output), 1)
|
||||
self.assertIn('EOF', messages.output[0])
|
||||
|
||||
def test_repeated_eof_preserves_existing_tcp_connection(self):
|
||||
async def run():
|
||||
loop = self.loop
|
||||
event_loop.install_self_pipe_guard(loop)
|
||||
finished = loop.create_future()
|
||||
|
||||
async def echo(reader, writer):
|
||||
try:
|
||||
while True:
|
||||
data = await reader.read(128)
|
||||
if not data:
|
||||
break
|
||||
writer.write(data)
|
||||
await writer.drain()
|
||||
finally:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
finished.set_result(None)
|
||||
|
||||
server = await asyncio.start_server(echo, '127.0.0.1', 0)
|
||||
writer = None
|
||||
try:
|
||||
port = server.sockets[0].getsockname()[1]
|
||||
reader, writer = await asyncio.open_connection('127.0.0.1', port)
|
||||
for index in range(5):
|
||||
old_writer = loop._csock
|
||||
old_writer.shutdown(socket.SHUT_WR)
|
||||
await _wait_for_replacement(loop, old_writer)
|
||||
payload = f'packet {index}'.encode()
|
||||
writer.write(payload)
|
||||
await writer.drain()
|
||||
data = await asyncio.wait_for(reader.readexactly(len(payload)), 1)
|
||||
self.assertEqual(data, payload)
|
||||
finally:
|
||||
if writer is not None:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
await asyncio.wait_for(finished, 1)
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
|
||||
with self.assertLogs(event_loop.log, 'WARNING'):
|
||||
self.loop.run_until_complete(run())
|
||||
|
||||
def test_reset_rebuilds_but_cancelled_and_stale_callbacks_do_not(self):
|
||||
async def run():
|
||||
loop = self.loop
|
||||
event_loop.install_self_pipe_guard(loop)
|
||||
await asyncio.sleep(0)
|
||||
old_writer = loop._csock
|
||||
pending = loop._self_reading_future
|
||||
pending.cancel()
|
||||
failed = loop.create_future()
|
||||
failed.set_exception(ConnectionResetError('self-pipe reset'))
|
||||
loop._self_reading_future = failed
|
||||
loop._loop_self_reading(failed)
|
||||
self.assertIsNot(loop._csock, old_writer)
|
||||
replacement = loop._csock
|
||||
with patch.object(loop, 'call_exception_handler') as report:
|
||||
loop._loop_self_reading(failed) # queued callback from the old pipe
|
||||
loop._loop_self_reading(pending) # cancellation during shutdown
|
||||
report.assert_not_called()
|
||||
self.assertIs(loop._csock, replacement)
|
||||
await asyncio.sleep(.01)
|
||||
|
||||
with self.assertLogs(event_loop.log, 'WARNING') as messages:
|
||||
self.loop.run_until_complete(run())
|
||||
self.assertEqual(len(messages.output), 1)
|
||||
|
||||
def test_reset_on_read_armed_before_installation_is_recovered(self):
|
||||
async def run():
|
||||
loop = self.loop
|
||||
await asyncio.sleep(.01)
|
||||
event_loop.install_self_pipe_guard(loop)
|
||||
old_writer = loop._csock
|
||||
loop._self_reading_future.set_exception(ConnectionResetError('self-pipe reset'))
|
||||
await _wait_for_replacement(loop, old_writer)
|
||||
self.assertFalse(loop._self_reading_future.done())
|
||||
|
||||
with self.assertLogs(event_loop.log, 'WARNING') as messages:
|
||||
self.loop.run_until_complete(run())
|
||||
self.assertEqual(len(messages.output), 1)
|
||||
|
||||
def test_recovery_in_background_proxy_thread(self):
|
||||
errors = []
|
||||
|
||||
async def run():
|
||||
loop = asyncio.get_running_loop()
|
||||
event_loop.install_self_pipe_guard(loop)
|
||||
await asyncio.sleep(0)
|
||||
old_writer = loop._csock
|
||||
old_writer.shutdown(socket.SHUT_WR)
|
||||
await _wait_for_replacement(loop, old_writer)
|
||||
self.assertFalse(loop._self_reading_future.done())
|
||||
|
||||
def worker():
|
||||
try:
|
||||
asyncio.run(run())
|
||||
except BaseException as exc:
|
||||
errors.append(exc)
|
||||
|
||||
with self.assertLogs(event_loop.log, 'WARNING'):
|
||||
thread = threading.Thread(target=worker, daemon=True)
|
||||
thread.start()
|
||||
thread.join(timeout=2)
|
||||
self.assertFalse(thread.is_alive())
|
||||
self.assertFalse(errors, errors)
|
||||
|
||||
def test_allocation_failure_retries_without_spinning(self):
|
||||
async def run():
|
||||
loop = self.loop
|
||||
event_loop.install_self_pipe_guard(loop)
|
||||
await asyncio.sleep(0)
|
||||
original_pair = socket.socketpair
|
||||
attempts = 0
|
||||
|
||||
def allocate():
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
if attempts == 1:
|
||||
raise OSError('temporarily out of sockets')
|
||||
return original_pair()
|
||||
|
||||
with patch.object(event_loop.socket, 'socketpair', side_effect=allocate):
|
||||
old_reader, old_writer = loop._ssock, loop._csock
|
||||
old_writer.shutdown(socket.SHUT_WR)
|
||||
await asyncio.sleep(.05)
|
||||
self.assertEqual(attempts, 1)
|
||||
self.assertIs(loop._csock, old_writer)
|
||||
self.assertNotEqual(old_reader.fileno(), -1)
|
||||
self.assertIsNone(loop._self_reading_future)
|
||||
await _wait_for_replacement(loop, old_writer, timeout=2)
|
||||
self.assertEqual(attempts, 2)
|
||||
|
||||
with self.assertLogs(event_loop.log, 'WARNING'):
|
||||
self.loop.run_until_complete(run())
|
||||
|
||||
def test_signal_wakeup_fd_is_moved_only_when_owned_by_the_loop(self):
|
||||
async def run():
|
||||
loop = self.loop
|
||||
event_loop.install_self_pipe_guard(loop)
|
||||
await asyncio.sleep(0)
|
||||
external_reader, external_writer = socket.socketpair()
|
||||
external_writer.setblocking(False)
|
||||
try:
|
||||
for external in (False, True):
|
||||
with self.subTest(external=external):
|
||||
if external:
|
||||
signal.set_wakeup_fd(external_writer.fileno())
|
||||
old_writer = loop._csock
|
||||
old_writer.shutdown(socket.SHUT_WR)
|
||||
await _wait_for_replacement(loop, old_writer)
|
||||
fd = signal.set_wakeup_fd(-1)
|
||||
expected = external_writer if external else loop._csock
|
||||
self.assertEqual(fd, expected.fileno())
|
||||
signal.set_wakeup_fd(loop._csock.fileno())
|
||||
finally:
|
||||
external_reader.close()
|
||||
external_writer.close()
|
||||
|
||||
with self.assertLogs(event_loop.log, 'WARNING'):
|
||||
self.loop.run_until_complete(run())
|
||||
|
||||
|
||||
@unittest.skipUnless(sys.platform == 'win32', 'Windows proactor self-pipe')
|
||||
class ProxySelfPipeTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_proxy_without_h2_can_stop_from_thread_after_eof(self):
|
||||
from proxy import tg_ws_proxy
|
||||
from proxy.config import ProxyConfig
|
||||
|
||||
config = ProxyConfig(port=0, dc_redirects={}, pool_size=0, cfproxy_h2_media=False)
|
||||
stop = asyncio.Event()
|
||||
loop = asyncio.get_running_loop()
|
||||
with patch.object(tg_ws_proxy, 'proxy_config', config), \
|
||||
patch.object(tg_ws_proxy, 'start_cfproxy_domain_refresh'), \
|
||||
patch.object(tg_ws_proxy.ws_pool, 'warmup', AsyncMock()), \
|
||||
patch.object(tg_ws_proxy.cf_worker_pool, 'warmup', AsyncMock()), \
|
||||
self.assertLogs(event_loop.log, 'WARNING'):
|
||||
task = asyncio.create_task(tg_ws_proxy._run(stop))
|
||||
thread = None
|
||||
try:
|
||||
async def listening():
|
||||
while tg_ws_proxy._server_instance is None:
|
||||
await asyncio.sleep(.005)
|
||||
await asyncio.wait_for(listening(), 1)
|
||||
self.assertIsNone(tg_ws_proxy.cf_h2_pool)
|
||||
old_writer = loop._csock
|
||||
old_writer.shutdown(socket.SHUT_WR)
|
||||
await _wait_for_replacement(loop, old_writer)
|
||||
thread = _wake_later(loop, stop.set)
|
||||
started = time.monotonic()
|
||||
await asyncio.wait_for(task, 1)
|
||||
self.assertLess(time.monotonic() - started, .5)
|
||||
self.assertIsNone(tg_ws_proxy._server_instance)
|
||||
finally:
|
||||
if thread is not None:
|
||||
thread.join(timeout=1)
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -27,6 +27,40 @@ from proxy.config import proxy_config
|
||||
from proxy.utils import PROTO_TAG_ABRIDGED, PROTO_TAG_INTERMEDIATE, PROTO_TAG_SECURE
|
||||
|
||||
|
||||
class H2ContextTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_lanes_reuse_verified_context_without_changing_websocket_contexts(self):
|
||||
from proxy.h2_transport import _default_ssl_context
|
||||
from proxy.raw_websocket import _ssl_ctx, _ssl_ctx_fronting
|
||||
from proxy.utils import create_ssl_context
|
||||
|
||||
_default_ssl_context.cache_clear()
|
||||
self.addCleanup(_default_ssl_context.cache_clear)
|
||||
lanes = []
|
||||
try:
|
||||
with patch('proxy.h2_transport.create_ssl_context', wraps=create_ssl_context) as create:
|
||||
lanes = [_HttpLane('kws4.example.org', 1), _HttpLane('kws2.example.net', 2)]
|
||||
create.assert_called_once_with()
|
||||
first, second = (lane.client._transport.ssl_context for lane in lanes)
|
||||
self.assertIs(first, second)
|
||||
self.assertIsNot(first, _ssl_ctx)
|
||||
self.assertIsNot(first, _ssl_ctx_fronting)
|
||||
self.assertTrue(first.check_hostname)
|
||||
self.assertEqual(first.verify_mode, ssl.CERT_REQUIRED)
|
||||
self.assertTrue(_ssl_ctx.check_hostname)
|
||||
self.assertFalse(_ssl_ctx_fronting.check_hostname)
|
||||
self.assertEqual(set(first.get_ca_certs(binary_form=True)),
|
||||
set(_ssl_ctx.get_ca_certs(binary_form=True)))
|
||||
|
||||
custom = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
custom.check_hostname = False
|
||||
transport = H2Transport(custom)
|
||||
self.assertIs(transport.ssl_context, custom)
|
||||
self.assertTrue(first.check_hostname)
|
||||
await transport.aclose()
|
||||
finally:
|
||||
await asyncio.gather(*(lane.close() for lane in lanes))
|
||||
|
||||
|
||||
class H2WireTest(unittest.IsolatedAsyncioTestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
@@ -163,6 +197,14 @@ class H2WireTest(unittest.IsolatedAsyncioTestCase):
|
||||
await asyncio.gather(slow1, slow2)
|
||||
self.assertEqual(len(self.connections), 1)
|
||||
|
||||
async def test_shared_default_context_rejects_untrusted_certificate(self):
|
||||
async with httpx.AsyncClient(transport=H2Transport(), trust_env=False) as client:
|
||||
with self.assertRaises(httpx.ConnectError):
|
||||
await client.get('https://' + self.lane.host + '/api')
|
||||
task, request = await self.post()
|
||||
self.respond(request)
|
||||
self.assertEqual(await asyncio.wait_for(task, 1), b'r' * 40)
|
||||
|
||||
async def test_debug_trace_follows_real_stream_without_logging_payload(self):
|
||||
channel = _HttpChannel(self.lane, 1, 'trace-test')
|
||||
body = b'private-payload-' + b'x' * 24
|
||||
@@ -565,8 +607,12 @@ class H2WireTest(unittest.IsolatedAsyncioTestCase):
|
||||
peers.append(peer)
|
||||
bodies = [b'\x00' * 8 + os.urandom(8) + struct.pack('<I', 20) + os.urandom(20)]
|
||||
bodies += [b'samekey!' + os.urandom(size - 8) for size in (40, 131112, 56)]
|
||||
# Maximum extended plaintext padding: MTProtoKit
|
||||
# (236 + 15) and Android (256), not just TCP's 15.
|
||||
plain_padding = (251, 256)[index]
|
||||
wire = init + up.update(b''.join(
|
||||
frame(body, tag, padding) for body, padding in zip(bodies, (15, 1, 7, 0))))
|
||||
frame(body, tag, padding)
|
||||
for body, padding in zip(bodies, (plain_padding, 1, 7, 0))))
|
||||
for start, end in ((0, 13), (13, 57), (57, 65), (65, 78), (78, len(wire))):
|
||||
writer.write(wire[start:end])
|
||||
await writer.drain()
|
||||
@@ -974,8 +1020,8 @@ class H2WireTest(unittest.IsolatedAsyncioTestCase):
|
||||
self.respond(request, b's' * (1024 * 1024))
|
||||
response = await opening
|
||||
fast, fast_request = await self.post()
|
||||
self.respond(fast_request, b'f' * (128 * 1024))
|
||||
self.assertEqual(await asyncio.wait_for(fast, .5), b'f' * (128 * 1024))
|
||||
self.respond(fast_request, b'f' * (4 * 1024 * 1024))
|
||||
self.assertEqual(await asyncio.wait_for(fast, 2), b'f' * (4 * 1024 * 1024))
|
||||
stream = self.transport.connection.streams[request[1]]
|
||||
self.assertLessEqual(sum(len(data) for data, _ in stream.chunks), STREAM_RECEIVE_WINDOW)
|
||||
await response.aclose()
|
||||
@@ -984,13 +1030,31 @@ class H2WireTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_read_timeout_resets_only_its_stream(self):
|
||||
self.lane.client.timeout = httpx.Timeout(2, read=.15)
|
||||
stalled, request = await self.post()
|
||||
self.lane.client.timeout = httpx.Timeout(2, read=1)
|
||||
healthy, healthy_request = await self.post()
|
||||
self.respond(healthy_request)
|
||||
self.assertEqual(await healthy, b'r' * 40)
|
||||
with self.assertRaises(httpx.ReadTimeout):
|
||||
await stalled
|
||||
self.assertEqual(await asyncio.wait_for(self.resets.get(), .5), (request[1], 8))
|
||||
self.assertIsNone(self.transport.connection.error)
|
||||
self.respond(healthy_request)
|
||||
self.assertEqual(await healthy, b'r' * 40)
|
||||
self.assertEqual(len(self.connections), 1)
|
||||
|
||||
async def test_last_stream_timeout_reconnects_instead_of_reusing_silent_connection(self):
|
||||
self.lane.client.timeout = httpx.Timeout(2, read=.05)
|
||||
stalled, _ = await self.post()
|
||||
old_connection = self.transport.connection
|
||||
with self.assertRaises(httpx.ReadTimeout):
|
||||
await stalled
|
||||
self.assertIsNotNone(old_connection.error)
|
||||
self.assertTrue(old_connection.writer.is_closing())
|
||||
self.assertFalse(old_connection.streams)
|
||||
self.lane.client.timeout = httpx.Timeout(2, read=1)
|
||||
recovered, request = await self.post()
|
||||
self.respond(request)
|
||||
self.assertEqual(await recovered, b'r' * 40)
|
||||
self.assertIsNot(self.transport.connection, old_connection)
|
||||
self.assertEqual(len(self.connections), 2)
|
||||
|
||||
async def test_peer_reset_does_not_break_other_streams(self):
|
||||
bad, request = await self.post()
|
||||
@@ -1040,7 +1104,8 @@ class H2WireTest(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(len(self.connections), 1)
|
||||
failures = [record for record in captured.records if record.levelname in ('WARNING', 'ERROR')]
|
||||
self.assertEqual(len(failures), 1)
|
||||
self.assertIn('reset: 2', failures[0].getMessage())
|
||||
# h2 4.1 (Python 3.8) formats IntEnum values by name.
|
||||
self.assertRegex(failures[0].getMessage(), r'reset: (?:2|ErrorCodes\.INTERNAL_ERROR)\b')
|
||||
self.assertIn('down=20', failures[0].getMessage())
|
||||
self.assertIn('sid=1', failures[0].getMessage())
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
LOCALES_DIR = Path(__file__).parents[1] / "ui" / "i18n"
|
||||
|
||||
# Values that are intentionally identical across locales
|
||||
# (brand names, technical terms, format-only strings).
|
||||
# Note: settings.language is deliberately "Language" in ru.json —
|
||||
# upstream keeps the language selector label in English on purpose.
|
||||
ALLOWED_UNTRANSLATED = frozenset({
|
||||
"app.name",
|
||||
"button.test_loading",
|
||||
"connectivity.cfworker_title",
|
||||
"connectivity.error_line",
|
||||
"first_run.manual_mtproto",
|
||||
"language.en",
|
||||
"language.ru",
|
||||
"section.cfproxy",
|
||||
"section.cfworker",
|
||||
"settings.language",
|
||||
})
|
||||
|
||||
|
||||
def _load(code: str) -> dict:
|
||||
with open(LOCALES_DIR / f"{code}.json", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
class I18nParityTest(unittest.TestCase):
|
||||
def test_en_ru_key_sets_match(self):
|
||||
en = _load("en")
|
||||
ru = _load("ru")
|
||||
self.assertEqual(
|
||||
set(en), set(ru),
|
||||
f"Missing in ru: {sorted(set(en) - set(ru))}; "
|
||||
f"missing in en: {sorted(set(ru) - set(en))}",
|
||||
)
|
||||
|
||||
def test_ru_has_no_untouched_english_leftovers(self):
|
||||
en = _load("en")
|
||||
ru = _load("ru")
|
||||
leftovers = sorted(
|
||||
key for key in set(en) & set(ru)
|
||||
if en[key] == ru[key] and key not in ALLOWED_UNTRANSLATED
|
||||
)
|
||||
self.assertEqual(leftovers, [])
|
||||
|
||||
def test_format_placeholders_match(self):
|
||||
import string
|
||||
|
||||
en = _load("en")
|
||||
ru = _load("ru")
|
||||
for key in set(en) & set(ru):
|
||||
en_fields = {
|
||||
name for _, name, _, _ in string.Formatter().parse(en[key])
|
||||
if name
|
||||
}
|
||||
ru_fields = {
|
||||
name for _, name, _, _ in string.Formatter().parse(ru[key])
|
||||
if name
|
||||
}
|
||||
self.assertEqual(
|
||||
en_fields, ru_fields,
|
||||
f"Placeholder mismatch for key {key!r}",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,405 @@
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import ExitStack
|
||||
from http.client import IncompleteRead
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from utils import linux_update, update_check
|
||||
|
||||
|
||||
class _Proc:
|
||||
def __init__(self, returncode, stdout=b'', stderr=b''):
|
||||
self.returncode = returncode
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
|
||||
|
||||
class AssetNameTest(unittest.TestCase):
|
||||
def test_names_match_release_assets(self):
|
||||
self.assertEqual(linux_update.asset_name('binary'), 'TgWsProxy_linux_amd64')
|
||||
self.assertEqual(linux_update.asset_name('deb'), 'TgWsProxy_linux_amd64.deb')
|
||||
self.assertEqual(linux_update.asset_name('rpm'), 'TgWsProxy_linux_amd64.rpm')
|
||||
|
||||
|
||||
class DetectInstallKindTest(unittest.TestCase):
|
||||
def _detect(self, *, machine='x86_64', tools=(), owned_by=None, writable=True):
|
||||
exe = Path('/usr/bin/tg-ws-proxy')
|
||||
|
||||
def _which(name):
|
||||
return '/usr/bin/' + name if name in tools else None
|
||||
|
||||
def _run_quiet(cmd):
|
||||
return cmd[0] == owned_by
|
||||
|
||||
with mock.patch.object(linux_update.platform, 'machine', return_value=machine), \
|
||||
mock.patch.object(linux_update.shutil, 'which', side_effect=_which), \
|
||||
mock.patch.object(linux_update, '_run_quiet', side_effect=_run_quiet), \
|
||||
mock.patch.object(linux_update.os, 'access', return_value=writable), \
|
||||
mock.patch.object(linux_update, '_is_root', return_value=False):
|
||||
return linux_update.detect_install_kind(exe)
|
||||
|
||||
def test_deb_package_with_pkexec(self):
|
||||
self.assertEqual(
|
||||
self._detect(tools=('dpkg-query', 'pkexec', 'apt-get'), owned_by='dpkg-query'),
|
||||
'deb',
|
||||
)
|
||||
|
||||
def test_rpm_package_with_pkexec(self):
|
||||
self.assertEqual(
|
||||
self._detect(tools=('rpm', 'pkexec'), owned_by='rpm'), 'rpm',
|
||||
)
|
||||
|
||||
def test_package_without_pkexec_is_unsupported(self):
|
||||
self.assertIsNone(self._detect(tools=('dpkg-query',), owned_by='dpkg-query'))
|
||||
|
||||
def test_aur_install_is_unsupported(self):
|
||||
self.assertIsNone(
|
||||
self._detect(tools=('pacman', 'pkexec'), owned_by='pacman'),
|
||||
)
|
||||
|
||||
def test_standalone_writable_binary(self):
|
||||
self.assertEqual(self._detect(tools=()), 'binary')
|
||||
|
||||
def test_read_only_dir_is_supported_via_pkexec(self):
|
||||
self.assertEqual(self._detect(tools=('pkexec',), writable=False), 'binary')
|
||||
|
||||
def test_read_only_dir_without_pkexec_is_unsupported(self):
|
||||
self.assertIsNone(self._detect(tools=(), writable=False))
|
||||
|
||||
def test_non_amd64_is_unsupported(self):
|
||||
self.assertIsNone(self._detect(machine='aarch64'))
|
||||
|
||||
|
||||
class InstallCmdTest(unittest.TestCase):
|
||||
package_path = Path(tempfile.gettempdir()) / 'pkg'
|
||||
|
||||
def _cmd(self, kind, tools):
|
||||
with mock.patch.object(
|
||||
linux_update.shutil, 'which',
|
||||
side_effect=lambda n: '/usr/bin/' + n if n in tools else None,
|
||||
), mock.patch.object(linux_update, '_is_root', return_value=False):
|
||||
return linux_update.install_cmd(kind, self.package_path)
|
||||
|
||||
def test_deb_prefers_apt_get_and_elevates(self):
|
||||
cmd = self._cmd('deb', ('apt-get',))
|
||||
self.assertEqual(cmd[:3], ['pkexec', 'apt-get', 'install'])
|
||||
self.assertEqual(cmd[-1], str(self.package_path))
|
||||
|
||||
def test_deb_falls_back_to_dpkg(self):
|
||||
self.assertEqual(self._cmd('deb', ()), ['pkexec', 'dpkg', '-i', str(self.package_path)])
|
||||
|
||||
def test_rpm_prefers_dnf_without_removing_packages(self):
|
||||
cmd = self._cmd('rpm', ('dnf',))
|
||||
self.assertEqual(cmd, ['pkexec', 'dnf', 'install', '-y', str(self.package_path)])
|
||||
self.assertNotIn('--allowerasing', cmd)
|
||||
|
||||
def test_rpm_falls_back_to_rpm_binary(self):
|
||||
self.assertEqual(
|
||||
self._cmd('rpm', ()), ['pkexec', 'rpm', '-U', str(self.package_path)],
|
||||
)
|
||||
|
||||
def test_unknown_kind_is_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self._cmd('binary', ('apt-get',))
|
||||
|
||||
def test_root_does_not_use_pkexec(self):
|
||||
with mock.patch.object(linux_update.shutil, 'which', return_value=None), \
|
||||
mock.patch.object(linux_update, '_is_root', return_value=True):
|
||||
self.assertEqual(
|
||||
linux_update.install_cmd('deb', self.package_path),
|
||||
['dpkg', '-i', str(self.package_path)],
|
||||
)
|
||||
|
||||
|
||||
class ApplyUpdateTest(unittest.TestCase):
|
||||
def _download(self, payload=b'new'):
|
||||
def _fn(url, dest_dir, suffix='.tmp', digest=''):
|
||||
path = Path(dest_dir) / ('new' + suffix)
|
||||
path.write_bytes(payload)
|
||||
return path
|
||||
return _fn
|
||||
|
||||
def test_writable_binary_is_replaced_in_place(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
exe = Path(tmp) / 'TgWsProxy'
|
||||
exe.write_bytes(b'old')
|
||||
|
||||
with mock.patch.object(linux_update, 'exe_path', return_value=exe), \
|
||||
mock.patch('utils.linux_update.download_asset', self._download()):
|
||||
self.assertIsNone(linux_update.apply_update('binary', 'https://x.invalid/a'))
|
||||
|
||||
self.assertEqual(exe.read_bytes(), b'new')
|
||||
self.assertEqual(list(Path(tmp).iterdir()), [exe])
|
||||
|
||||
@unittest.skipUnless(sys.platform == 'linux', 'Linux executable permissions')
|
||||
def test_replaced_binary_has_executable_permissions(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
exe = Path(tmp) / 'TgWsProxy'
|
||||
exe.write_bytes(b'old')
|
||||
with mock.patch.object(linux_update, 'exe_path', return_value=exe), \
|
||||
mock.patch('utils.linux_update.download_asset', self._download()):
|
||||
self.assertIsNone(linux_update.apply_update('binary', 'https://x.invalid/a'))
|
||||
self.assertEqual(stat.S_IMODE(exe.stat().st_mode), 0o755)
|
||||
|
||||
def test_read_only_dir_binary_is_installed_with_elevation(self):
|
||||
exe = Path('/usr/local/bin/TgWsProxy')
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
def _download(url, dest_dir, suffix='.tmp', digest=''):
|
||||
path = Path(tmp) / ('new' + suffix)
|
||||
path.write_bytes(b'new')
|
||||
return path
|
||||
|
||||
run = mock.Mock(return_value=_Proc(0))
|
||||
with mock.patch.object(linux_update, 'exe_path', return_value=exe), \
|
||||
mock.patch.object(linux_update.os, 'access', return_value=False), \
|
||||
mock.patch.object(linux_update, '_is_root', return_value=False), \
|
||||
mock.patch('utils.linux_update.download_asset', _download), \
|
||||
mock.patch.object(linux_update.subprocess, 'run', run):
|
||||
self.assertIsNone(linux_update.apply_update('binary', 'https://x.invalid/a'))
|
||||
|
||||
cmd = run.call_args[0][0]
|
||||
self.assertEqual(cmd[:3], ['pkexec', 'sh', '-c'])
|
||||
self.assertEqual(cmd[-1], str(exe))
|
||||
|
||||
def test_deb_install_failure_returns_stderr(self):
|
||||
run = mock.Mock(return_value=_Proc(1, stderr=b'dpkg: error'))
|
||||
with mock.patch.object(linux_update, 'exe_path', return_value=Path('/usr/bin/x')), \
|
||||
mock.patch('utils.linux_update.download_asset', self._download()), \
|
||||
mock.patch.object(linux_update.subprocess, 'run', run), \
|
||||
mock.patch.object(linux_update.shutil, 'which', return_value=None), \
|
||||
mock.patch.object(linux_update, '_is_root', return_value=True):
|
||||
err = linux_update.apply_update('deb', 'https://x.invalid/a.deb')
|
||||
|
||||
self.assertEqual(err, 'dpkg: error')
|
||||
|
||||
def test_deb_install_success_removes_downloaded_package(self):
|
||||
holder = {}
|
||||
|
||||
def _download(url, dest_dir, suffix='.tmp', digest=''):
|
||||
path = Path(dest_dir) / ('pkg' + suffix)
|
||||
path.write_bytes(b'deb')
|
||||
holder['path'] = path
|
||||
return path
|
||||
|
||||
with mock.patch.object(linux_update, 'exe_path', return_value=Path('/usr/bin/x')), \
|
||||
mock.patch('utils.linux_update.download_asset', _download), \
|
||||
mock.patch.object(linux_update.subprocess, 'run',
|
||||
return_value=_Proc(0)), \
|
||||
mock.patch.object(linux_update.shutil, 'which', return_value=None), \
|
||||
mock.patch.object(linux_update, '_is_root', return_value=True):
|
||||
self.assertIsNone(linux_update.apply_update('deb', 'https://x.invalid/a.deb'))
|
||||
|
||||
self.assertFalse(holder['path'].exists())
|
||||
|
||||
def test_digest_is_passed_to_the_downloader(self):
|
||||
seen = {}
|
||||
|
||||
def _download(url, dest_dir, suffix='.tmp', digest=''):
|
||||
seen['digest'] = digest
|
||||
path = Path(dest_dir) / ('new' + suffix)
|
||||
path.write_bytes(b'new')
|
||||
return path
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
exe = Path(tmp) / 'TgWsProxy'
|
||||
exe.write_bytes(b'old')
|
||||
with mock.patch.object(linux_update, 'exe_path', return_value=exe), \
|
||||
mock.patch('utils.linux_update.download_asset', _download):
|
||||
linux_update.apply_update('binary', 'https://x.invalid/a', digest='sha256:ab')
|
||||
|
||||
self.assertEqual(seen['digest'], 'sha256:ab')
|
||||
|
||||
def test_download_failure_is_reported(self):
|
||||
for error in (OSError('boom'), ValueError('invalid checksum'), IncompleteRead(b'partial', 100)):
|
||||
with self.subTest(error=error), \
|
||||
mock.patch.object(linux_update, 'exe_path', return_value=Path('/tmp/x')), \
|
||||
mock.patch.object(linux_update, 'download_asset', side_effect=error), \
|
||||
mock.patch.object(linux_update, '_run_install') as install:
|
||||
self.assertEqual(linux_update.apply_update('binary', 'https://x.invalid/a'), str(error))
|
||||
install.assert_not_called()
|
||||
|
||||
def test_corrupt_download_never_changes_binary_or_runs_a_package_manager(self):
|
||||
for kind in ('binary', 'deb', 'rpm'):
|
||||
with self.subTest(kind=kind), tempfile.TemporaryDirectory() as tmp:
|
||||
exe = Path(tmp) / 'TgWsProxy'
|
||||
exe.write_bytes(b'old')
|
||||
opener = mock.Mock()
|
||||
opener.open.return_value = io.BytesIO(b'corrupt')
|
||||
with mock.patch.object(linux_update, 'exe_path', return_value=exe), \
|
||||
mock.patch.object(update_check, 'build_github_opener', return_value=opener), \
|
||||
mock.patch.object(linux_update, '_run_install') as install:
|
||||
error = linux_update.apply_update(kind, 'https://example.invalid/asset',
|
||||
digest='sha256:' + '0' * 64)
|
||||
self.assertIn('SHA-256', error)
|
||||
install.assert_not_called()
|
||||
self.assertEqual(exe.read_bytes(), b'old')
|
||||
self.assertEqual(list(Path(tmp).iterdir()), [exe])
|
||||
|
||||
def test_verified_download_is_installed(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
exe = Path(tmp) / 'TgWsProxy'
|
||||
exe.write_bytes(b'old')
|
||||
opener = mock.Mock()
|
||||
opener.open.return_value = io.BytesIO(b'new')
|
||||
with mock.patch.object(linux_update, 'exe_path', return_value=exe), \
|
||||
mock.patch.object(update_check, 'build_github_opener', return_value=opener):
|
||||
self.assertIsNone(linux_update.apply_update(
|
||||
'binary', 'https://example.invalid/asset',
|
||||
digest='sha256:' + hashlib.sha256(b'new').hexdigest(),
|
||||
))
|
||||
self.assertEqual(exe.read_bytes(), b'new')
|
||||
self.assertEqual(list(Path(tmp).iterdir()), [exe])
|
||||
|
||||
def test_install_timeout_cleans_download_directory(self):
|
||||
holder = {}
|
||||
|
||||
def download(url, dest_dir, **kwargs):
|
||||
holder['directory'] = dest_dir
|
||||
path = dest_dir / 'update.deb'
|
||||
path.write_bytes(b'package')
|
||||
return path
|
||||
|
||||
with mock.patch.object(linux_update, 'download_asset', side_effect=download), \
|
||||
mock.patch.object(linux_update, '_run_install', side_effect=subprocess.TimeoutExpired('apt-get', 600)):
|
||||
self.assertIsNotNone(linux_update.apply_update('deb', 'https://example.invalid/asset'))
|
||||
self.assertFalse(holder['directory'].exists())
|
||||
|
||||
|
||||
@unittest.skipUnless(sys.platform == 'linux', 'Linux install(1) and executable replacement')
|
||||
class ElevatedBinaryReplacementTest(unittest.TestCase):
|
||||
def test_replaces_a_running_executable_with_special_characters_in_its_path(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
directory = Path(tmp)
|
||||
exe = directory / 'app $(touch unwanted); тест'
|
||||
src = directory / 'download with spaces'
|
||||
shutil.copy2(shutil.which('sleep'), exe)
|
||||
src.write_bytes(b'new')
|
||||
process = subprocess.Popen([str(exe), '30'])
|
||||
try:
|
||||
with mock.patch.object(linux_update, '_is_root', return_value=True):
|
||||
cmd = linux_update.replace_binary_cmd(src, exe)
|
||||
self.assertEqual(subprocess.run(cmd, cwd=tmp, timeout=10).returncode, 0)
|
||||
self.assertIsNone(process.poll())
|
||||
self.assertEqual(exe.read_bytes(), b'new')
|
||||
self.assertEqual(stat.S_IMODE(exe.stat().st_mode), 0o755)
|
||||
self.assertEqual(set(directory.iterdir()), {exe, src})
|
||||
finally:
|
||||
process.terminate()
|
||||
process.wait(timeout=5)
|
||||
|
||||
def test_failed_copy_preserves_the_old_binary_and_removes_the_stage(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
exe = Path(tmp) / 'app'
|
||||
exe.write_bytes(b'old')
|
||||
with mock.patch.object(linux_update, '_is_root', return_value=True):
|
||||
cmd = linux_update.replace_binary_cmd(Path(tmp) / 'missing', exe)
|
||||
result = subprocess.run(cmd, stderr=subprocess.PIPE, timeout=10)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(exe.read_bytes(), b'old')
|
||||
self.assertEqual(list(Path(tmp).iterdir()), [exe])
|
||||
|
||||
|
||||
class ExternalCommandsEnvTest(unittest.TestCase):
|
||||
def test_commands_use_system_libraries_without_changing_parent_environment(self):
|
||||
bundle = str(Path(tempfile.gettempdir()) / '_MEItest')
|
||||
probe = (
|
||||
'import os, sys; '
|
||||
'assert os.environ.get("LD_LIBRARY_PATH", "") == sys.argv[1]; '
|
||||
'assert "_PYI_APPLICATION_HOME_DIR" not in os.environ'
|
||||
)
|
||||
for original in ('/usr/lib', None):
|
||||
for runner, expected in ((linux_update._run_quiet, True),
|
||||
(linux_update._run_install, None)):
|
||||
with self.subTest(original=original, runner=runner.__name__):
|
||||
env = dict(os.environ)
|
||||
env.update(LD_LIBRARY_PATH=bundle, _PYI_APPLICATION_HOME_DIR=bundle)
|
||||
env.pop('LD_LIBRARY_PATH_ORIG', None)
|
||||
if original is not None:
|
||||
env['LD_LIBRARY_PATH_ORIG'] = original
|
||||
with mock.patch.dict(os.environ, env, clear=True), \
|
||||
mock.patch.object(sys, '_MEIPASS', bundle, create=True):
|
||||
self.assertEqual(
|
||||
runner([sys.executable, '-c', probe, original or '']), expected,
|
||||
)
|
||||
self.assertEqual(os.environ['LD_LIBRARY_PATH'], bundle)
|
||||
self.assertEqual(os.environ['_PYI_APPLICATION_HOME_DIR'], bundle)
|
||||
|
||||
|
||||
class RelaunchEnvTest(unittest.TestCase):
|
||||
def _env(self, env, meipass='/tmp/_MEI1'):
|
||||
with mock.patch.dict(linux_update.os.environ, env, clear=True), \
|
||||
mock.patch.object(linux_update.sys, '_MEIPASS', meipass, create=True):
|
||||
return linux_update.relaunch_env()
|
||||
|
||||
def test_bundle_paths_are_dropped_and_originals_restored(self):
|
||||
out = self._env({
|
||||
'_PYI_APPLICATION_HOME_DIR': '/tmp/_MEI1',
|
||||
'_MEIPASS': '/tmp/_MEI1',
|
||||
'LD_LIBRARY_PATH': '/tmp/_MEI1',
|
||||
'LD_LIBRARY_PATH_ORIG': '/usr/lib',
|
||||
'GI_TYPELIB_PATH': '/tmp/_MEI1',
|
||||
'PATH': os.pathsep.join(('/tmp/_MEI1', '/usr/bin')),
|
||||
'HOME': '/home/u',
|
||||
})
|
||||
|
||||
self.assertEqual(out['LD_LIBRARY_PATH'], '/usr/lib')
|
||||
self.assertNotIn('GI_TYPELIB_PATH', out)
|
||||
self.assertEqual(out['PATH'], '/usr/bin')
|
||||
self.assertNotIn('_MEIPASS', out)
|
||||
self.assertNotIn('_PYI_APPLICATION_HOME_DIR', out)
|
||||
self.assertEqual(out['HOME'], '/home/u')
|
||||
|
||||
def test_environment_without_bundle_is_unchanged(self):
|
||||
env = {'PATH': '/usr/bin', 'HOME': '/home/u'}
|
||||
with mock.patch.dict(linux_update.os.environ, env, clear=True):
|
||||
with mock.patch.object(linux_update.sys, '_MEIPASS', None, create=True):
|
||||
out = linux_update.relaunch_env()
|
||||
self.assertEqual(out, env)
|
||||
|
||||
|
||||
class RestartAfterUpdateTest(unittest.TestCase):
|
||||
def test_restart_releases_resources_before_launch_and_preserves_arguments(self):
|
||||
with mock.patch.dict(sys.modules, {'pystray': mock.Mock()}):
|
||||
import linux
|
||||
|
||||
events = []
|
||||
exe = str(Path(tempfile.gettempdir()) / 'proxy')
|
||||
with mock.patch.object(linux, 'stop_proxy', side_effect=lambda: events.append('stop')), \
|
||||
mock.patch.object(linux, 'release_lock', side_effect=lambda: events.append('unlock')), \
|
||||
mock.patch.object(linux_update, 'exe_path', return_value=Path(exe)), \
|
||||
mock.patch.object(linux, 'relaunch_env', return_value={'PATH': '/usr/bin'}), \
|
||||
mock.patch.object(sys, 'argv', ['proxy', '--portable']), \
|
||||
mock.patch.object(linux.subprocess, 'Popen', side_effect=lambda *a, **k: events.append('launch')) as launch, \
|
||||
mock.patch.object(linux.os, '_exit', side_effect=lambda code: events.append('exit')):
|
||||
linux._restart_after_update()
|
||||
self.assertEqual(events, ['stop', 'unlock', 'launch', 'exit'])
|
||||
self.assertEqual(launch.call_args[0][0], [exe, '--portable'])
|
||||
self.assertEqual(launch.call_args[1]['env'], {'PATH': '/usr/bin'})
|
||||
|
||||
def test_launch_failure_recovers_the_running_proxy_instead_of_exiting(self):
|
||||
with mock.patch.dict(sys.modules, {'pystray': mock.Mock()}):
|
||||
import linux
|
||||
|
||||
with ExitStack() as stack:
|
||||
mocks = {name: stack.enter_context(mock.patch.object(linux, name)) for name in (
|
||||
'stop_proxy', 'release_lock', 'acquire_lock', 'start_proxy', '_show_error',
|
||||
)}
|
||||
exit_process = stack.enter_context(mock.patch.object(linux.os, '_exit'))
|
||||
stack.enter_context(mock.patch.object(linux.subprocess, 'Popen', side_effect=OSError('launch failed')))
|
||||
linux._restart_after_update()
|
||||
mocks['acquire_lock'].assert_called_once_with()
|
||||
mocks['start_proxy'].assert_called_once_with(linux._config, mocks['_show_error'])
|
||||
mocks['_show_error'].assert_called_once()
|
||||
exit_process.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
+27
-1
@@ -7,7 +7,7 @@ from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
from proxy.config import proxy_config
|
||||
from proxy.pool import _WsPool
|
||||
from proxy.pool import _CfWorkerPool, _WsPool
|
||||
from proxy.raw_websocket import RawWebSocket, WsHandshakeError
|
||||
from proxy.utils import ws_domains
|
||||
|
||||
@@ -241,5 +241,31 @@ class WsPoolTest(unittest.IsolatedAsyncioTestCase):
|
||||
idle.writer.close.assert_called_once()
|
||||
|
||||
|
||||
class CfWorkerPoolTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_close_cancels_refill_and_closes_unclaimed_connections(self):
|
||||
pool = _CfWorkerPool()
|
||||
ready = _open_ws()
|
||||
idle = _open_ws()
|
||||
started = asyncio.Event()
|
||||
pool._idle[2] = deque([(idle, time.monotonic(), 'a.example')])
|
||||
|
||||
async def connect(*args):
|
||||
started.set()
|
||||
try:
|
||||
await asyncio.Future()
|
||||
except asyncio.CancelledError:
|
||||
return ready, 'b.example'
|
||||
|
||||
with mock.patch.object(proxy_config, 'pool_size', 1), \
|
||||
mock.patch.object(pool, '_connect_one', side_effect=connect):
|
||||
pool._schedule_refill(4, '192.0.2.4', ['b.example'])
|
||||
await asyncio.wait_for(started.wait(), 1)
|
||||
await pool.close()
|
||||
self.assertFalse(pool._idle)
|
||||
self.assertFalse(pool._refilling)
|
||||
ready.writer.close.assert_called_once()
|
||||
idle.writer.close.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -36,6 +36,15 @@ def _recv(chunks, cls=RawWebSocket):
|
||||
|
||||
|
||||
class XorMaskTest(unittest.TestCase):
|
||||
def test_matches_bytewise_xor_at_fast_path_and_frame_boundaries(self):
|
||||
for length in (0, 1, 2, 3, 4, 5, 125, 126, 127, 255, 511, 512, 513, 514, 515,
|
||||
16384, 65535, 65536, 65537, 1024 * 1024 + 3):
|
||||
data = (bytes(range(256)) * (length // 256 + 1))[:length]
|
||||
for mask in (b'\x00' * 4, b'\xff' * 4, b'\x01\x7f\x80\xff'):
|
||||
with self.subTest(length=length, mask=mask):
|
||||
expected = bytes(value ^ mask[index % 4] for index, value in enumerate(data))
|
||||
self.assertEqual(_xor_mask(data, mask), expected)
|
||||
|
||||
def test_roundtrip(self):
|
||||
data = bytes(range(256)) * 3
|
||||
mask = b'\x01\x02\x03\x04'
|
||||
@@ -46,6 +55,21 @@ class XorMaskTest(unittest.TestCase):
|
||||
|
||||
|
||||
class BuildFrameTest(unittest.TestCase):
|
||||
def test_masked_frame_wire_format_across_all_length_encodings(self):
|
||||
mask = b'\x01\x7f\x80\xff'
|
||||
for length in (1, 125, 126, 511, 512, 513, 65535, 65536, 65537):
|
||||
payload = (bytes(range(256)) * (length // 256 + 1))[:length]
|
||||
with self.subTest(length=length), patch('proxy.raw_websocket.os.urandom', return_value=mask):
|
||||
frame = RawWebSocket._build_frame(RawWebSocket.OP_BINARY, payload, mask=True)
|
||||
if length < 126:
|
||||
header = bytes([0x82, 0x80 | length])
|
||||
elif length < 65536:
|
||||
header = b'\x82\xfe' + length.to_bytes(2, 'big')
|
||||
else:
|
||||
header = b'\x82\xff' + length.to_bytes(8, 'big')
|
||||
masked = bytes(value ^ mask[index % 4] for index, value in enumerate(payload))
|
||||
self.assertEqual(frame, header + mask + masked)
|
||||
|
||||
def test_short_unmasked_frame(self):
|
||||
self.assertEqual(
|
||||
RawWebSocket._build_frame(RawWebSocket.OP_BINARY, b'abc'),
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from utils import tray_common
|
||||
|
||||
|
||||
class CheckUpdateAsyncTest(unittest.TestCase):
|
||||
def test_disabled_check_does_not_start_a_thread(self):
|
||||
called = []
|
||||
with mock.patch.object(tray_common.threading, 'Thread') as thread:
|
||||
tray_common.check_update_async(
|
||||
{'check_updates': False}, lambda: False, lambda v, u: called.append(v),
|
||||
)
|
||||
thread.assert_not_called()
|
||||
self.assertEqual(called, [])
|
||||
|
||||
def test_update_is_reported_to_the_handler(self):
|
||||
status = {'has_update': True, 'latest': '2.0.0', 'html_url': 'https://x.invalid/r'}
|
||||
seen = []
|
||||
with mock.patch.object(tray_common.threading, 'Thread') as thread, \
|
||||
mock.patch.object(tray_common.time, 'sleep'), \
|
||||
mock.patch('utils.update_check.run_check'), \
|
||||
mock.patch('utils.update_check.get_status', return_value=status):
|
||||
tray_common.check_update_async(
|
||||
{'check_updates': True}, lambda: False,
|
||||
lambda version, url: seen.append((version, url)),
|
||||
)
|
||||
thread.call_args.kwargs['target']()
|
||||
|
||||
self.assertEqual(seen, [('2.0.0', 'https://x.invalid/r')])
|
||||
|
||||
def test_no_update_means_no_handler_call(self):
|
||||
seen = []
|
||||
with mock.patch.object(tray_common.threading, 'Thread') as thread, \
|
||||
mock.patch.object(tray_common.time, 'sleep'), \
|
||||
mock.patch('utils.update_check.run_check'), \
|
||||
mock.patch('utils.update_check.get_status',
|
||||
return_value={'has_update': False}):
|
||||
tray_common.check_update_async(
|
||||
{'check_updates': True}, lambda: False,
|
||||
lambda version, url: seen.append(version),
|
||||
)
|
||||
thread.call_args.kwargs['target']()
|
||||
|
||||
self.assertEqual(seen, [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,7 +1,22 @@
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from http.client import IncompleteRead
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
from urllib.error import HTTPError, URLError
|
||||
|
||||
from utils import update_check
|
||||
from utils.update_check import _extract_assets, _parse_version_tuple, _version_gt
|
||||
|
||||
PAYLOAD = b'payload'
|
||||
DIGEST = 'sha256:' + hashlib.sha256(PAYLOAD).hexdigest()
|
||||
|
||||
|
||||
class ParseVersionTupleTest(unittest.TestCase):
|
||||
def test_plain_and_prefixed_versions(self):
|
||||
@@ -66,5 +81,257 @@ class ExtractAssetsTest(unittest.TestCase):
|
||||
self.assertEqual(_extract_assets({}), [])
|
||||
|
||||
|
||||
class _Response(io.BytesIO):
|
||||
def __init__(self, body, content_length='auto'):
|
||||
super().__init__(body)
|
||||
if content_length == 'auto':
|
||||
content_length = len(body)
|
||||
self.headers = {} if content_length is None else {
|
||||
'Content-Length': str(content_length),
|
||||
}
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.close()
|
||||
return False
|
||||
|
||||
|
||||
def _opener(response):
|
||||
opener = mock.Mock()
|
||||
opener.open.return_value = response
|
||||
return mock.Mock(return_value=opener)
|
||||
|
||||
|
||||
class DownloadAssetTest(unittest.TestCase):
|
||||
def _download(self, response, digest=DIGEST):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with mock.patch.object(update_check, 'build_github_opener', _opener(response)):
|
||||
path = update_check.download_asset(
|
||||
'https://example.invalid/a', Path(tmp), suffix='.bin', digest=digest,
|
||||
)
|
||||
try:
|
||||
return path.read_bytes(), sorted(p.name for p in Path(tmp).iterdir())
|
||||
finally:
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
def test_complete_download_is_kept(self):
|
||||
body, files = self._download(_Response(b'payload'))
|
||||
self.assertEqual(body, b'payload')
|
||||
self.assertEqual(len(files), 1)
|
||||
|
||||
def test_matching_digest_is_accepted(self):
|
||||
digest = 'sha256:' + hashlib.sha256(b'payload').hexdigest()
|
||||
body, _ = self._download(_Response(b'payload'), digest=digest)
|
||||
self.assertEqual(body, b'payload')
|
||||
|
||||
def test_invalid_or_missing_digest_is_rejected_before_any_download(self):
|
||||
for digest in ('', None, 123, 'md5:whatever', 'sha256:ab', 'sha256:' + 'z' * 64):
|
||||
with self.subTest(digest=digest), tempfile.TemporaryDirectory() as tmp:
|
||||
with mock.patch.object(update_check, 'build_github_opener') as opener:
|
||||
with self.assertRaises(ValueError):
|
||||
update_check.download_asset('https://example.invalid/a', Path(tmp), digest=digest)
|
||||
opener.assert_not_called()
|
||||
self.assertEqual(list(Path(tmp).iterdir()), [])
|
||||
|
||||
def test_truncated_download_raises_and_leaves_no_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
resp = _Response(b'pay', content_length=None)
|
||||
with mock.patch.object(update_check, 'build_github_opener', _opener(resp)):
|
||||
with self.assertRaises(OSError):
|
||||
update_check.download_asset(
|
||||
'https://example.invalid/a', Path(tmp), suffix='.bin', digest=DIGEST,
|
||||
)
|
||||
self.assertEqual(list(Path(tmp).iterdir()), [])
|
||||
|
||||
def test_digest_mismatch_raises_and_leaves_no_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
resp = _Response(b'payload')
|
||||
with mock.patch.object(update_check, 'build_github_opener', _opener(resp)):
|
||||
with self.assertRaises(OSError):
|
||||
update_check.download_asset(
|
||||
'https://example.invalid/a', Path(tmp),
|
||||
suffix='.bin', digest='sha256:' + '0' * 64,
|
||||
)
|
||||
self.assertEqual(list(Path(tmp).iterdir()), [])
|
||||
|
||||
def test_missing_content_length_is_tolerated(self):
|
||||
body, _ = self._download(_Response(b'payload', content_length=None))
|
||||
self.assertEqual(body, b'payload')
|
||||
|
||||
def test_same_length_corruption_is_rejected(self):
|
||||
with self.assertRaisesRegex(OSError, 'SHA-256'):
|
||||
self._download(_Response(b'payloae'))
|
||||
|
||||
def test_uppercase_checksum_is_accepted(self):
|
||||
body, _ = self._download(_Response(PAYLOAD), digest=DIGEST.upper())
|
||||
self.assertEqual(body, PAYLOAD)
|
||||
|
||||
def test_stream_failure_removes_partial_file(self):
|
||||
for error in (TimeoutError('read timed out'), IncompleteRead(b'pay', 4)):
|
||||
with self.subTest(error=error), tempfile.TemporaryDirectory() as tmp:
|
||||
response = mock.MagicMock()
|
||||
response.__enter__.return_value = response
|
||||
response.read.side_effect = [b'pay', error]
|
||||
with mock.patch.object(update_check, 'build_github_opener', _opener(response)):
|
||||
with self.assertRaises(type(error)):
|
||||
update_check.download_asset('https://example.invalid/a', Path(tmp), digest=DIGEST)
|
||||
self.assertEqual(list(Path(tmp).iterdir()), [])
|
||||
|
||||
def test_hash_covers_every_chunk_and_read_timeout_is_set(self):
|
||||
body = PAYLOAD * 20000
|
||||
opener = _opener(_Response(body))
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with mock.patch.object(update_check, 'build_github_opener', opener):
|
||||
path = update_check.download_asset(
|
||||
'https://example.invalid/a', Path(tmp),
|
||||
digest='sha256:' + hashlib.sha256(body).hexdigest(), timeout=7,
|
||||
)
|
||||
self.assertEqual(path.read_bytes(), body)
|
||||
opener.return_value.open.assert_called_once_with('https://example.invalid/a', timeout=7)
|
||||
|
||||
|
||||
class FindAssetTest(unittest.TestCase):
|
||||
def test_returns_asset_with_digest(self):
|
||||
assets = [{'name': 'a.deb', 'url': 'https://example.invalid/a.deb', 'digest': DIGEST}]
|
||||
with mock.patch.dict(update_check._state, {'assets': assets}):
|
||||
self.assertEqual(update_check.find_asset('a.deb'), assets[0])
|
||||
self.assertIsNone(update_check.find_asset('b.deb'))
|
||||
|
||||
def test_ignores_asset_without_url(self):
|
||||
with mock.patch.dict(update_check._state, {'assets': [{'name': 'a.deb', 'url': ''}]}):
|
||||
self.assertIsNone(update_check.find_asset('a.deb'))
|
||||
|
||||
def test_assets_without_a_valid_checksum_require_manual_installation(self):
|
||||
for digest in ('', 'sha256:ab', 'md5:' + '0' * 32):
|
||||
asset = {'name': 'a.exe', 'url': 'https://example.invalid/a.exe', 'digest': digest}
|
||||
with self.subTest(digest=digest), mock.patch.dict(update_check._state, {'assets': [asset]}):
|
||||
self.assertIsNone(update_check.find_asset('a.exe'))
|
||||
|
||||
|
||||
class RunCheckTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.patch = mock.patch.object(update_check, '_state', update_check.get_status())
|
||||
self.patch.start()
|
||||
self.addCleanup(self.patch.stop)
|
||||
|
||||
def _check(self, data, current='1.11.1'):
|
||||
with mock.patch.object(update_check, 'fetch_latest_release', return_value=data):
|
||||
update_check.run_check(current)
|
||||
return update_check.get_status()
|
||||
|
||||
def test_versions_are_compared_and_assets_published_together(self):
|
||||
release = {'tag_name': ' v2.0 ', 'assets': [{
|
||||
'name': 'app.exe', 'browser_download_url': 'https://example.invalid/app.exe', 'digest': DIGEST,
|
||||
}]}
|
||||
for current, has_update, ahead in (('1.11.1', True, False), ('2.0.0', False, False), ('2.1', False, True)):
|
||||
with self.subTest(current=current):
|
||||
state = self._check(release, current)
|
||||
self.assertTrue(state['checked'])
|
||||
self.assertIsNone(state['error'])
|
||||
self.assertEqual((state['has_update'], state['ahead_of_release']), (has_update, ahead))
|
||||
self.assertEqual(state['latest'], '2.0')
|
||||
self.assertEqual(state['html_url'], update_check.RELEASES_PAGE_URL)
|
||||
self.assertEqual(state['assets'][0]['digest'], DIGEST)
|
||||
|
||||
def test_invalid_response_is_an_error_not_a_claim_that_the_app_is_current(self):
|
||||
for data in (None, [], {}, {'tag_name': None}, {'tag_name': ''}, {'tag_name': 'oops'},
|
||||
{'tag_name': 'v2.0', 'assets': 'invalid'}):
|
||||
with self.subTest(data=data):
|
||||
state = self._check(data)
|
||||
self.assertTrue(state['error'])
|
||||
self.assertFalse(state['has_update'])
|
||||
self.assertIsNone(state['latest'])
|
||||
self.assertEqual(state['assets'], [])
|
||||
|
||||
def test_release_tag_suffixes_keep_the_existing_version_comparison(self):
|
||||
state = self._check({'tag_name': 'v1.12.0-hotfix'})
|
||||
self.assertIsNone(state['error'])
|
||||
self.assertTrue(state['has_update'])
|
||||
self.assertEqual(state['latest'], '1.12.0-hotfix')
|
||||
|
||||
def test_network_failure_clears_old_assets_and_does_not_prevent_retry(self):
|
||||
errors = (URLError('offline'), HTTPError('https://example.invalid', 403, 'rate limit', {}, None),
|
||||
TimeoutError(), IncompleteRead(b'{', 5), ValueError('bad json'))
|
||||
for error in errors:
|
||||
with self.subTest(error=error):
|
||||
self._check({'tag_name': 'v2.0', 'assets': [{
|
||||
'name': 'a.exe', 'browser_download_url': 'https://example.invalid/a.exe', 'digest': DIGEST,
|
||||
}]})
|
||||
with mock.patch.object(update_check, 'fetch_latest_release', side_effect=error):
|
||||
update_check.run_check('1.11.1')
|
||||
state = update_check.get_status()
|
||||
self.assertTrue(state['error'])
|
||||
self.assertEqual(state['assets'], [])
|
||||
self.assertFalse(state['has_update'])
|
||||
self.assertIsNone(self._check({'tag_name': 'v2.1'})['error'])
|
||||
|
||||
def test_readers_do_not_see_partially_updated_status(self):
|
||||
previous = self._check({'tag_name': 'v1.11.1'})
|
||||
started, finish = threading.Event(), threading.Event()
|
||||
|
||||
def fetch():
|
||||
started.set()
|
||||
finish.wait(timeout=5)
|
||||
return {'tag_name': 'v2.0'}
|
||||
|
||||
with mock.patch.object(update_check, 'fetch_latest_release', side_effect=fetch):
|
||||
worker = threading.Thread(target=update_check.run_check, args=('1.11.1',))
|
||||
worker.start()
|
||||
try:
|
||||
self.assertTrue(started.wait(timeout=5))
|
||||
self.assertEqual(update_check.get_status(), previous)
|
||||
finally:
|
||||
finish.set()
|
||||
worker.join(timeout=5)
|
||||
self.assertEqual(update_check.get_status()['latest'], '2.0')
|
||||
|
||||
def test_asset_and_status_snapshots_cannot_modify_internal_state(self):
|
||||
self._check({'tag_name': 'v2.0', 'assets': [{
|
||||
'name': 'a.exe', 'browser_download_url': 'https://example.invalid/a.exe', 'digest': DIGEST,
|
||||
}]})
|
||||
asset = update_check.find_asset('a.exe')
|
||||
asset['digest'] = 'bad'
|
||||
snapshot = update_check.get_status()
|
||||
snapshot['assets'][0]['digest'] = 'bad'
|
||||
snapshot['assets'].clear()
|
||||
self.assertEqual(update_check.find_asset('a.exe')['digest'], DIGEST)
|
||||
|
||||
def test_old_cache_is_ignored_and_no_new_cache_is_written(self):
|
||||
from utils import tray_common
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / '.update_check_cache.json'
|
||||
old = '{"last_attempt_at": "invalid", "assets": "invalid"}'
|
||||
path.write_text(old, encoding='utf-8')
|
||||
with mock.patch.object(tray_common, 'APP_DIR', Path(tmp)):
|
||||
self.assertTrue(self._check({'tag_name': 'v2.0'})['has_update'])
|
||||
self.assertEqual(path.read_text(encoding='utf-8'), old)
|
||||
self.assertEqual(list(Path(tmp).iterdir()), [path])
|
||||
|
||||
def test_fetch_is_one_request_without_etag_and_with_timeout(self):
|
||||
opener = _opener(_Response(json.dumps({'tag_name': 'v2.0'}).encode()))
|
||||
with mock.patch.object(update_check, 'build_github_opener', opener):
|
||||
self.assertEqual(update_check.fetch_latest_release(timeout=7)['tag_name'], 'v2.0')
|
||||
args, kwargs = opener.return_value.open.call_args
|
||||
self.assertEqual(args[0].full_url, update_check.RELEASES_LATEST_API)
|
||||
self.assertFalse(args[0].has_header('If-none-match'))
|
||||
self.assertEqual(kwargs['timeout'], 7)
|
||||
|
||||
|
||||
class RelaunchEnvTest(unittest.TestCase):
|
||||
def test_old_and_new_bootloader_variables_and_bundle_subdirectories_are_removed(self):
|
||||
bundle = str(Path(tempfile.gettempdir()) / '_MEItest')
|
||||
env = {
|
||||
'_PYI_APPLICATION_HOME_DIR': bundle, '_PYI_ARCHIVE_FILE': 'old.exe',
|
||||
'_MEIPASS': bundle, '_MEIPASS2': bundle,
|
||||
'PATH': os.pathsep.join((bundle, bundle + os.sep + 'lib', bundle + '-other')),
|
||||
}
|
||||
with mock.patch.dict(os.environ, env, clear=True), mock.patch.object(sys, '_MEIPASS', bundle, create=True):
|
||||
result = update_check.relaunch_env()
|
||||
self.assertEqual(result, {'PATH': bundle + '-other', 'PYINSTALLER_RESET_ENVIRONMENT': '1'})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,169 @@
|
||||
import hashlib
|
||||
import io
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import ExitStack
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from utils import update_check, windows_update
|
||||
|
||||
|
||||
class AssetNameTest(unittest.TestCase):
|
||||
def test_keeps_the_running_build_even_when_renamed_or_emulated(self):
|
||||
builds = (
|
||||
('win-amd64', (3, 11), 'TgWsProxy_windows.exe'),
|
||||
('win-amd64', (3, 8), 'TgWsProxy_windows_7_64bit.exe'),
|
||||
('win32', (3, 8), 'TgWsProxy_windows_7_32bit.exe'),
|
||||
('win-arm64', (3, 11), 'TgWsProxy_windows_arm64.exe'),
|
||||
('unknown', (3, 11), None),
|
||||
)
|
||||
for platform, version, expected in builds:
|
||||
with self.subTest(platform=platform, version=version), \
|
||||
mock.patch.object(windows_update.sysconfig, 'get_platform', return_value=platform), \
|
||||
mock.patch.object(sys, 'version_info', version), \
|
||||
mock.patch.object(sys, 'executable', 'renamed.exe'):
|
||||
self.assertEqual(windows_update.asset_name(), expected)
|
||||
|
||||
|
||||
class ApplyUpdateTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
stack = ExitStack()
|
||||
self.addCleanup(stack.close)
|
||||
directory = stack.enter_context(tempfile.TemporaryDirectory())
|
||||
self.exe = Path(directory) / 'renamed app.exe'
|
||||
self.exe.write_bytes(b'old')
|
||||
self.backup = self.exe.with_name('renamed app_oldtgws.exe')
|
||||
self.opener = mock.Mock()
|
||||
self.opener.open.return_value = io.BytesIO(b'new')
|
||||
self.asset = {'url': 'https://example.invalid/new.exe',
|
||||
'digest': 'sha256:' + hashlib.sha256(b'new').hexdigest()}
|
||||
stack.enter_context(mock.patch.object(sys, 'executable', str(self.exe)))
|
||||
stack.enter_context(mock.patch.object(sys, 'argv', [str(self.exe), '--portable']))
|
||||
stack.enter_context(mock.patch.object(sys, '_MEIPASS', None, create=True))
|
||||
stack.enter_context(mock.patch.object(update_check, 'build_github_opener', return_value=self.opener))
|
||||
stack.enter_context(mock.patch.object(windows_update.subprocess, 'DETACHED_PROCESS', 8, create=True))
|
||||
stack.enter_context(mock.patch.object(windows_update.subprocess, 'CREATE_NEW_PROCESS_GROUP', 512, create=True))
|
||||
self.launch = stack.enter_context(mock.patch.object(windows_update.subprocess, 'Popen'))
|
||||
self.prepare = mock.Mock()
|
||||
|
||||
def test_verified_update_keeps_legacy_backup_and_launch_arguments(self):
|
||||
def prepare():
|
||||
self.assertEqual(self.exe.read_bytes(), b'new')
|
||||
self.assertEqual(self.backup.read_bytes(), b'old')
|
||||
self.launch.assert_not_called()
|
||||
|
||||
windows_update.apply_update(self.asset, prepare)
|
||||
self.assertEqual(self.exe.read_bytes(), b'new')
|
||||
self.assertEqual(self.backup.read_bytes(), b'old')
|
||||
self.assertEqual(set(self.exe.parent.iterdir()), {self.exe, self.backup})
|
||||
self.assertEqual(self.launch.call_args[0][0], [str(self.exe), '--portable'])
|
||||
self.assertEqual(self.launch.call_args[1]['creationflags'], 520)
|
||||
|
||||
def test_missing_or_corrupt_checksum_does_not_replace_or_launch(self):
|
||||
for digest in ('', 'sha256:' + '0' * 64):
|
||||
with self.subTest(digest=digest):
|
||||
self.asset['digest'] = digest
|
||||
with self.assertRaises((OSError, ValueError)):
|
||||
windows_update.apply_update(self.asset, self.prepare)
|
||||
self.assertEqual(self.exe.read_bytes(), b'old')
|
||||
self.assertEqual(list(self.exe.parent.iterdir()), [self.exe])
|
||||
self.prepare.assert_not_called()
|
||||
self.launch.assert_not_called()
|
||||
|
||||
def test_launch_failure_restores_the_old_executable(self):
|
||||
self.launch.side_effect = OSError('launch failed')
|
||||
with self.assertRaisesRegex(OSError, 'launch failed'):
|
||||
windows_update.apply_update(self.asset, self.prepare)
|
||||
self.prepare.assert_called_once_with()
|
||||
self.assertEqual(self.exe.read_bytes(), b'old')
|
||||
self.assertEqual(list(self.exe.parent.iterdir()), [self.exe])
|
||||
|
||||
def test_rename_failure_leaves_current_executable_and_cleans_download(self):
|
||||
with mock.patch.object(Path, 'rename', side_effect=PermissionError('in use')):
|
||||
with self.assertRaises(PermissionError):
|
||||
windows_update.apply_update(self.asset, self.prepare)
|
||||
self.assertEqual(self.exe.read_bytes(), b'old')
|
||||
self.assertEqual(list(self.exe.parent.iterdir()), [self.exe])
|
||||
self.prepare.assert_not_called()
|
||||
self.launch.assert_not_called()
|
||||
|
||||
def test_replacement_failure_rolls_back_and_cleans_download(self):
|
||||
replace = Path.replace
|
||||
|
||||
def fail_download_move(path, target):
|
||||
if path.suffix == '.tmp':
|
||||
raise PermissionError('move failed')
|
||||
return replace(path, target)
|
||||
|
||||
with mock.patch.object(Path, 'replace', fail_download_move):
|
||||
with self.assertRaisesRegex(PermissionError, 'move failed'):
|
||||
windows_update.apply_update(self.asset, self.prepare)
|
||||
self.assertEqual(self.exe.read_bytes(), b'old')
|
||||
self.assertEqual(list(self.exe.parent.iterdir()), [self.exe])
|
||||
self.prepare.assert_not_called()
|
||||
self.launch.assert_not_called()
|
||||
|
||||
def test_restart_uses_a_fresh_bundle_and_resets_dll_directory(self):
|
||||
dll = mock.Mock()
|
||||
with mock.patch.object(sys, '_MEIPASS', 'old-bundle'), \
|
||||
mock.patch.object(windows_update.ctypes, 'windll', dll, create=True):
|
||||
windows_update.apply_update(self.asset, self.prepare)
|
||||
self.assertEqual(self.launch.call_args[1]['env']['PYINSTALLER_RESET_ENVIRONMENT'], '1')
|
||||
calls = dll.kernel32.SetDllDirectoryW.call_args_list
|
||||
self.assertEqual(calls[0], mock.call(None))
|
||||
self.assertEqual(calls[1].args[0].value, 'old-bundle')
|
||||
|
||||
|
||||
@unittest.skipUnless(sys.platform == 'win32', 'Windows tray integration')
|
||||
class UpdateFlowTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
import windows
|
||||
|
||||
self.windows = windows
|
||||
|
||||
def test_fallback_dialog_actually_installs_the_selected_asset(self):
|
||||
app = self.windows
|
||||
asset = {'url': 'https://example.invalid/new.exe', 'digest': 'sha256:' + '0' * 64}
|
||||
with mock.patch.object(app, 'ctk', None), \
|
||||
mock.patch.object(app._u32, 'MessageBoxW', return_value=app._IDYES), \
|
||||
mock.patch.object(app, '_perform_update') as update:
|
||||
app.update_ctk_form('update', asset=asset)
|
||||
update.assert_called_once_with(asset)
|
||||
|
||||
def test_failed_launch_recovers_proxy_and_lock_instead_of_exiting(self):
|
||||
app = self.windows
|
||||
|
||||
def fail(asset, before_restart):
|
||||
before_restart()
|
||||
raise OSError('launch failed')
|
||||
|
||||
with ExitStack() as stack:
|
||||
mocks = {name: stack.enter_context(mock.patch.object(app, name)) for name in (
|
||||
'apply_update', 'stop_proxy', 'release_lock', '_release_win_mutex',
|
||||
'_acquire_win_mutex', 'acquire_lock', 'start_proxy', '_show_error',
|
||||
)}
|
||||
exit_process = stack.enter_context(mock.patch.object(app.os, '_exit'))
|
||||
mocks['apply_update'].side_effect = fail
|
||||
mocks['_acquire_win_mutex'].return_value = None
|
||||
app._perform_update({'url': 'asset', 'digest': 'digest'})
|
||||
mocks['stop_proxy'].assert_called_once_with()
|
||||
mocks['release_lock'].assert_called_once_with()
|
||||
mocks['_release_win_mutex'].assert_called_once_with()
|
||||
mocks['acquire_lock'].assert_called_once_with()
|
||||
mocks['start_proxy'].assert_called_once_with(app._config, mocks['_show_error'])
|
||||
mocks['_show_error'].assert_called_once()
|
||||
exit_process.assert_not_called()
|
||||
|
||||
def test_download_failure_leaves_proxy_and_locks_running(self):
|
||||
app = self.windows
|
||||
with mock.patch.object(app, 'apply_update', side_effect=OSError('checksum mismatch')), \
|
||||
mock.patch.object(app, 'stop_proxy') as stop, \
|
||||
mock.patch.object(app, 'release_lock') as unlock, \
|
||||
mock.patch.object(app, '_show_error'), \
|
||||
mock.patch.object(app.os, '_exit') as exit_process:
|
||||
app._perform_update({})
|
||||
stop.assert_not_called()
|
||||
unlock.assert_not_called()
|
||||
exit_process.assert_not_called()
|
||||
@@ -32,7 +32,7 @@ class BackgroundTask:
|
||||
try:
|
||||
results.put((True, work()))
|
||||
except Exception:
|
||||
log.exception("Connectivity test failed")
|
||||
log.exception("Background task failed")
|
||||
results.put((False, None))
|
||||
|
||||
def poll():
|
||||
@@ -52,5 +52,5 @@ class BackgroundTask:
|
||||
if not self.closed:
|
||||
finish()
|
||||
|
||||
threading.Thread(target=run, daemon=True, name="connectivity-test").start()
|
||||
threading.Thread(target=run, daemon=True, name="ui-background-task").start()
|
||||
self.pending = self.owner.after(50, poll)
|
||||
@@ -75,6 +75,18 @@ def create_ctk_toplevel(
|
||||
after_create: Optional[Callable[[Any], None]] = None,
|
||||
) -> Any:
|
||||
root = ctk.CTkToplevel()
|
||||
|
||||
if sys.platform == "win32":
|
||||
def handle_ctrl_shortcut(event: Any) -> Optional[str]:
|
||||
shortcuts = {65: ("a", "<<SelectAll>>"), 67: ("c", "<<Copy>>"), 86: ("v", "<<Paste>>")}
|
||||
shortcut = shortcuts.get(event.keycode)
|
||||
if shortcut and event.keysym.lower() != shortcut[0] and not event.state & 0x20000:
|
||||
event.widget.event_generate(shortcut[1])
|
||||
return "break"
|
||||
return None
|
||||
|
||||
root.bind("<Control-KeyPress>", handle_ctrl_shortcut, add="+")
|
||||
|
||||
root.title(title)
|
||||
root.resizable(False, False)
|
||||
center_ctk_geometry(root, width, height)
|
||||
|
||||
+6
-5
@@ -67,8 +67,10 @@
|
||||
"button.open_release": "Open release page",
|
||||
"button.start": "Get started",
|
||||
"button.update": "Update",
|
||||
"button.check_updates": "Check for updates",
|
||||
"button.page": "Page",
|
||||
"button.close": "Close",
|
||||
"button.donate": "Donate ♥",
|
||||
|
||||
"validation.bad_host": "Invalid IP address.",
|
||||
"validation.bad_port": "Port must be a number between 1 and 65535",
|
||||
@@ -96,7 +98,8 @@
|
||||
"connectivity.multi_partial": "~ {domain}: working {ok_list}; unreachable {fail_list}",
|
||||
|
||||
"updates.status_error": "Could not reach GitHub. Check your network.",
|
||||
"updates.status_pending": "Status will appear after the background check on startup.",
|
||||
"updates.status_pending": "Updates have not been checked yet.",
|
||||
"updates.status_checking": "Checking for updates...",
|
||||
"updates.status_available": "Version {latest} is available on GitHub (you have {current}).",
|
||||
"updates.status_ahead": "You have {current} — newer than the latest GitHub release ({latest}).",
|
||||
"updates.status_latest": "Latest known version from GitHub is installed.",
|
||||
@@ -136,13 +139,11 @@
|
||||
|
||||
"update.available": "New version available: {version}",
|
||||
"update.ask_open": "New version available: {version}\n\nOpen the release page in the browser?",
|
||||
"update.ask_install": "New version available: {version}\n\nUpdate now? The app will restart.",
|
||||
"update.install_fail": "Failed to install the update:\n{error}\n\nOpen the release page in the browser?",
|
||||
"update.downloading": "Downloading...",
|
||||
"update.replacing": "Replacing file...",
|
||||
"update.restarting": "Restarting...",
|
||||
"update.error": "Error: {msg}",
|
||||
"update.download_fail": "Download failed:\n{error}",
|
||||
"update.rename_fail": "Failed to rename file:\n{error}",
|
||||
"update.move_fail": "Failed to move file:\n{error}",
|
||||
|
||||
"error.dc_config": "DC → IP configuration error.",
|
||||
|
||||
|
||||
+10
-9
@@ -28,7 +28,7 @@
|
||||
|
||||
"label.host": "IP-адрес",
|
||||
"label.port": "Порт",
|
||||
"label.secret": "Secret",
|
||||
"label.secret": "Ключ",
|
||||
"label.dc_hint": "По одному правилу на строку, формат: номер:IP",
|
||||
"label.cf_enable": "Включить CF-прокси",
|
||||
"label.cf_custom_domain": "Свой домен",
|
||||
@@ -67,13 +67,15 @@
|
||||
"button.open_release": "Открыть страницу релиза",
|
||||
"button.start": "Начать",
|
||||
"button.update": "Обновить",
|
||||
"button.check_updates": "Проверить обновления",
|
||||
"button.page": "Страница",
|
||||
"button.close": "Закрыть",
|
||||
"button.donate": "Поддержать ♥",
|
||||
|
||||
"validation.bad_host": "Некорректный IP-адрес.",
|
||||
"validation.bad_port": "Порт должен быть числом 1-65535",
|
||||
"validation.bad_secret_len": "Secret должен содержать ровно 32 hex-символа (16 байт).",
|
||||
"validation.bad_secret_hex": "Secret должен состоять только из hex-символов (0-9, a-f).",
|
||||
"validation.bad_secret_len": "Ключ должен содержать ровно 32 hex-символа (16 байт).",
|
||||
"validation.bad_secret_hex": "Ключ должен состоять только из hex-символов (0-9, a-f).",
|
||||
"validation.dc_format": "Неверный формат DC:IP: {entry}",
|
||||
"validation.dc_invalid": "Неверная запись DC:IP: {entry}",
|
||||
|
||||
@@ -96,7 +98,8 @@
|
||||
"connectivity.multi_partial": "~ {domain}: работают {ok_list}; недоступны {fail_list}",
|
||||
|
||||
"updates.status_error": "Не удалось связаться с GitHub. Проверьте сеть.",
|
||||
"updates.status_pending": "Статус появится после фоновой проверки при запуске.",
|
||||
"updates.status_pending": "Обновления ещё не проверялись.",
|
||||
"updates.status_checking": "Проверка обновлений...",
|
||||
"updates.status_available": "На GitHub доступна версия {latest} (у вас {current}).",
|
||||
"updates.status_ahead": "У вас {current} — новее последнего релиза на GitHub ({latest}).",
|
||||
"updates.status_latest": "Установлена последняя известная версия с GitHub.",
|
||||
@@ -109,7 +112,7 @@
|
||||
"first_run.manual": " Вручную:",
|
||||
"first_run.manual_path": " Настройки → Продвинутые → Тип подключения → Прокси",
|
||||
"first_run.manual_mtproto": " MTProto → {host} : {port}",
|
||||
"first_run.manual_secret": " Secret: dd{secret}",
|
||||
"first_run.manual_secret": " Ключ: dd{secret}",
|
||||
"first_run.open_now": "Открыть прокси в Telegram сейчас",
|
||||
|
||||
"tray.open_telegram": "Открыть в Telegram ({host}:{port})",
|
||||
@@ -136,13 +139,11 @@
|
||||
|
||||
"update.available": "Доступна новая версия: {version}",
|
||||
"update.ask_open": "Доступна новая версия: {version}\n\nОткрыть страницу релиза в браузере?",
|
||||
"update.ask_install": "Доступна новая версия: {version}\n\nОбновить сейчас? Приложение будет перезапущено.",
|
||||
"update.install_fail": "Не удалось установить обновление:\n{error}\n\nОткрыть страницу релиза в браузере?",
|
||||
"update.downloading": "Скачивание...",
|
||||
"update.replacing": "Замена файла...",
|
||||
"update.restarting": "Перезапуск...",
|
||||
"update.error": "Ошибка: {msg}",
|
||||
"update.download_fail": "Не удалось скачать:\n{error}",
|
||||
"update.rename_fail": "Не удалось переименовать файл:\n{error}",
|
||||
"update.move_fail": "Не удалось переместить файл:\n{error}",
|
||||
|
||||
"error.dc_config": "Ошибка конфигурации DC → IP.",
|
||||
|
||||
|
||||
+10
-3
@@ -17,7 +17,7 @@ class SettingsDialog:
|
||||
def __init__(
|
||||
self, *, ctk, root, widgets, config, defaults, persist,
|
||||
refresh_menu, finish, restart, include_autostart=False,
|
||||
apply_autostart=None,
|
||||
apply_autostart=None, read_autostart=None,
|
||||
):
|
||||
self.ctk = ctk
|
||||
self.root = root
|
||||
@@ -30,6 +30,7 @@ class SettingsDialog:
|
||||
self.restart = restart
|
||||
self.include_autostart = include_autostart
|
||||
self.apply_autostart = apply_autostart
|
||||
self.read_autostart = read_autostart
|
||||
self.closed = False
|
||||
|
||||
def cancel(self):
|
||||
@@ -55,7 +56,12 @@ class SettingsDialog:
|
||||
messagebox.showerror(t("app.error_title"), values, parent=self.root)
|
||||
return
|
||||
change = prepare_settings(self.config, values, self.defaults)
|
||||
if not change.changed_keys:
|
||||
autostart_changed = (
|
||||
self.include_autostart
|
||||
and self.read_autostart is not None
|
||||
and bool(values["autostart"]) != self.read_autostart()
|
||||
)
|
||||
if not change.changed_keys and not autostart_changed:
|
||||
self._finish()
|
||||
return
|
||||
try:
|
||||
@@ -66,7 +72,8 @@ class SettingsDialog:
|
||||
return
|
||||
self.config.update(deepcopy(change.config))
|
||||
set_language(change.config["language"])
|
||||
log.info("Settings saved: %s", ", ".join(sorted(change.changed_keys)))
|
||||
changed_keys = change.changed_keys | ({"autostart"} if autostart_changed else set())
|
||||
log.info("Settings saved: %s", ", ".join(sorted(changed_keys)))
|
||||
if self.apply_autostart is not None:
|
||||
self.apply_autostart(bool(change.config.get("autostart", False)))
|
||||
self.refresh_menu()
|
||||
|
||||
+71
-45
@@ -7,7 +7,7 @@ from typing import Any, Callable, Dict, List, Optional, Union
|
||||
|
||||
from proxy import __version__, coerce_domain_list
|
||||
from proxy.balancer import balancer
|
||||
from utils.update_check import RELEASES_PAGE_URL, get_status
|
||||
from utils.update_check import RELEASES_PAGE_URL, get_status, run_check
|
||||
from ui.background_task import BackgroundTask
|
||||
from ui.connectivity import (
|
||||
run_cfproxy_multi_test, run_cfproxy_auto_test, run_cfworker_multi_test,
|
||||
@@ -135,7 +135,7 @@ def _create_header(ctk, frame, theme):
|
||||
).pack(side="right", padx=(4, 0))
|
||||
|
||||
ctk.CTkButton(
|
||||
header, text="Donate ♥", width=90, height=28,
|
||||
header, text=t("button.donate"), width=90, height=28,
|
||||
font=(theme.ui_font_family, 13, "bold"), corner_radius=8,
|
||||
fg_color="#22c55e", hover_color="#16a34a",
|
||||
text_color="#ffffff", border_width=0,
|
||||
@@ -481,7 +481,6 @@ def _create_logging(ctk, frame, theme, cfg, default_config, no_secure_var):
|
||||
|
||||
def _create_updates(ctk, frame, theme, cfg, default_config, on_update_click):
|
||||
upd_inner = create_config_section(ctk, frame, theme, t("section.updates"))
|
||||
st = get_status()
|
||||
check_updates_var = ctk.BooleanVar(
|
||||
master=frame, value=bool(cfg.get("check_updates", default_config.get("check_updates", True)))
|
||||
)
|
||||
@@ -489,50 +488,77 @@ def _create_updates(ctk, frame, theme, cfg, default_config, on_update_click):
|
||||
upd_cb.pack(anchor="w", pady=(0, 6))
|
||||
attach_ctk_tooltip(upd_cb, t("tip.check_updates"))
|
||||
|
||||
if st.get("error"):
|
||||
upd_status = t("updates.status_error")
|
||||
elif not st.get("checked"):
|
||||
upd_status = t("updates.status_pending")
|
||||
elif st.get("has_update") and st.get("latest"):
|
||||
upd_status = t("updates.status_available", latest=st["latest"], current=__version__)
|
||||
elif st.get("ahead_of_release") and st.get("latest"):
|
||||
upd_status = t("updates.status_ahead", current=__version__, latest=st["latest"])
|
||||
else:
|
||||
upd_status = t("updates.status_latest")
|
||||
status_label = create_label(ctk, upd_inner, theme, "", size=11,
|
||||
justify="left", wraplength=_INNER_W)
|
||||
status_label.pack(anchor="w", pady=(0, 8))
|
||||
check_task = BackgroundTask(frame.winfo_toplevel())
|
||||
|
||||
create_label(ctk, upd_inner, theme, upd_status, size=11,
|
||||
justify="left", wraplength=_INNER_W).pack(anchor="w", pady=(0, 8))
|
||||
def check():
|
||||
if check_task.running or check_task.closed:
|
||||
return
|
||||
check_button.configure(state="disabled")
|
||||
status_label.configure(text=t("updates.status_checking"))
|
||||
check_task.start(
|
||||
lambda: run_check(__version__), lambda _: refresh(),
|
||||
lambda: check_button.configure(state="normal"),
|
||||
)
|
||||
|
||||
rel_url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
|
||||
if st.get("has_update") and on_update_click is not None:
|
||||
upd_btn_row = ctk.CTkFrame(upd_inner, fg_color="transparent")
|
||||
upd_btn_row.pack(fill="x")
|
||||
upd_btn_row.grid_columnconfigure(0, weight=1)
|
||||
upd_btn_row.grid_columnconfigure(1, weight=1)
|
||||
ctk.CTkButton(
|
||||
upd_btn_row, text=t("button.open_release"), height=32,
|
||||
font=(theme.ui_font_family, 13), corner_radius=8,
|
||||
fg_color=theme.field_bg, hover_color=theme.field_border,
|
||||
text_color=theme.text_primary, border_width=1,
|
||||
border_color=theme.field_border,
|
||||
command=lambda u=rel_url: webbrowser.open(u),
|
||||
).grid(row=0, column=0, sticky="ew", padx=(0, 4))
|
||||
ctk.CTkButton(
|
||||
upd_btn_row, text=t("button.update"), height=32,
|
||||
font=(theme.ui_font_family, 13, "bold"), corner_radius=8,
|
||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||
text_color="#ffffff",
|
||||
command=on_update_click,
|
||||
).grid(row=0, column=1, sticky="ew", padx=(4, 0))
|
||||
else:
|
||||
ctk.CTkButton(
|
||||
upd_inner, text=t("button.open_release"), height=32,
|
||||
font=(theme.ui_font_family, 13), corner_radius=8,
|
||||
fg_color=theme.field_bg, hover_color=theme.field_border,
|
||||
text_color=theme.text_primary, border_width=1,
|
||||
border_color=theme.field_border,
|
||||
command=lambda u=rel_url: webbrowser.open(u),
|
||||
).pack(anchor="w")
|
||||
check_button = ctk.CTkButton(
|
||||
upd_inner, text=t("button.check_updates"), height=32,
|
||||
font=(theme.ui_font_family, 13), corner_radius=8,
|
||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||
text_color="#ffffff", command=check,
|
||||
)
|
||||
upd_btn_row = ctk.CTkFrame(upd_inner, fg_color="transparent")
|
||||
upd_btn_row.pack(fill="x")
|
||||
upd_btn_row.grid_columnconfigure(0, weight=1)
|
||||
release_button = ctk.CTkButton(
|
||||
upd_btn_row, text=t("button.open_release"), height=32,
|
||||
font=(theme.ui_font_family, 13), corner_radius=8,
|
||||
fg_color=theme.field_bg, hover_color=theme.field_border,
|
||||
text_color=theme.text_primary, border_width=1,
|
||||
border_color=theme.field_border,
|
||||
)
|
||||
release_button.grid(row=0, column=0, sticky="ew")
|
||||
update_button = ctk.CTkButton(
|
||||
upd_btn_row, text=t("button.update"), height=32,
|
||||
font=(theme.ui_font_family, 13, "bold"), corner_radius=8,
|
||||
fg_color=theme.tg_blue, hover_color=theme.tg_blue_hover,
|
||||
text_color="#ffffff", command=on_update_click,
|
||||
)
|
||||
|
||||
def refresh():
|
||||
st = get_status()
|
||||
if st.get("error"):
|
||||
status = t("updates.status_error")
|
||||
elif not st.get("checked"):
|
||||
status = t("updates.status_pending")
|
||||
elif st.get("has_update") and st.get("latest"):
|
||||
status = t("updates.status_available", latest=st["latest"], current=__version__)
|
||||
elif st.get("ahead_of_release") and st.get("latest"):
|
||||
status = t("updates.status_ahead", current=__version__, latest=st["latest"])
|
||||
else:
|
||||
status = t("updates.status_latest")
|
||||
status_label.configure(text=status)
|
||||
rel_url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
|
||||
release_button.configure(command=lambda: webbrowser.open(rel_url))
|
||||
can_update = bool(st.get("has_update") and on_update_click is not None)
|
||||
upd_btn_row.grid_columnconfigure(1, weight=int(can_update))
|
||||
release_button.grid_configure(padx=(0, 4) if can_update else 0)
|
||||
if can_update:
|
||||
update_button.grid(row=0, column=1, sticky="ew", padx=(4, 0))
|
||||
else:
|
||||
update_button.grid_remove()
|
||||
|
||||
def sync_manual_check(*_):
|
||||
if check_updates_var.get():
|
||||
check_button.pack_forget()
|
||||
else:
|
||||
check_button.pack(fill="x", pady=(0, 8), before=upd_btn_row)
|
||||
|
||||
check_updates_var.trace_add("write", sync_manual_check)
|
||||
sync_manual_check()
|
||||
refresh()
|
||||
return check_updates_var
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
In-place update on Linux.
|
||||
|
||||
Detects how the app is installed (deb/rpm package or a standalone binary)
|
||||
and updates it without a manual download: packages go through the package
|
||||
manager, a standalone binary is replaced with the release file. Without
|
||||
write access the install runs through pkexec. Unsupported setups (AUR, pip,
|
||||
non-amd64, no pkexec) yield None and the caller shows the release page.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from http.client import HTTPException
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from utils.update_check import download_asset, relaunch_env
|
||||
|
||||
BINARY_ASSET = "TgWsProxy_linux_amd64"
|
||||
|
||||
# install kind -> release asset suffix
|
||||
ASSET_SUFFIX = {"binary": "", "deb": ".deb", "rpm": ".rpm"}
|
||||
|
||||
_INSTALL_TIMEOUT_SEC = 600.0
|
||||
|
||||
|
||||
def _run_quiet(cmd: List[str]) -> bool:
|
||||
try:
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=15,
|
||||
env=relaunch_env(),
|
||||
).returncode == 0
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
|
||||
|
||||
def exe_path() -> Path:
|
||||
return Path(os.path.realpath(sys.executable))
|
||||
|
||||
|
||||
def _is_root() -> bool:
|
||||
return hasattr(os, "geteuid") and os.geteuid() == 0
|
||||
|
||||
|
||||
def _can_elevate() -> bool:
|
||||
return _is_root() or bool(shutil.which("pkexec"))
|
||||
|
||||
|
||||
def _elevate() -> List[str]:
|
||||
return [] if _is_root() else ["pkexec"]
|
||||
|
||||
|
||||
def detect_install_kind(exe: Optional[Path] = None) -> Optional[str]:
|
||||
"""How the app is installed: "deb", "rpm", "binary" or None."""
|
||||
exe = exe or exe_path()
|
||||
if platform.machine().lower() not in ("x86_64", "amd64"):
|
||||
return None # releases ship amd64 only
|
||||
if shutil.which("pacman") and _run_quiet(["pacman", "-Qo", str(exe)]):
|
||||
return None # AUR install, updated by its own package manager
|
||||
|
||||
can_elevate = _can_elevate()
|
||||
if shutil.which("dpkg-query") and _run_quiet(["dpkg-query", "-S", str(exe)]):
|
||||
return "deb" if can_elevate else None
|
||||
if shutil.which("rpm") and _run_quiet(["rpm", "-qf", str(exe)]):
|
||||
return "rpm" if can_elevate else None
|
||||
|
||||
# replacing the binary needs a writable directory or root
|
||||
if os.access(str(exe.parent), os.W_OK) or can_elevate:
|
||||
return "binary"
|
||||
return None
|
||||
|
||||
|
||||
def asset_name(kind: str) -> str:
|
||||
return BINARY_ASSET + ASSET_SUFFIX[kind]
|
||||
|
||||
|
||||
def install_cmd(kind: str, path: Path) -> List[str]:
|
||||
"""Command that installs the downloaded package."""
|
||||
elevate = _elevate()
|
||||
if kind == "deb":
|
||||
if shutil.which("apt-get"):
|
||||
return elevate + [
|
||||
"apt-get", "install", "-y", str(path),
|
||||
]
|
||||
return elevate + ["dpkg", "-i", str(path)]
|
||||
if kind == "rpm":
|
||||
if shutil.which("dnf"):
|
||||
return elevate + ["dnf", "install", "-y", str(path)]
|
||||
return elevate + ["rpm", "-U", str(path)]
|
||||
raise ValueError("no install command for kind: {0}".format(kind))
|
||||
|
||||
|
||||
def replace_binary_cmd(src: Path, exe: Path) -> List[str]:
|
||||
"""Stage beside the executable so an interrupted copy cannot destroy it."""
|
||||
script = '''set -e
|
||||
staged=$(mktemp "$2.XXXXXX")
|
||||
trap 'rm -f -- "$staged"' EXIT
|
||||
install -m 755 -- "$1" "$staged"
|
||||
mv -fT -- "$staged" "$2"
|
||||
'''
|
||||
return _elevate() + ["sh", "-c", script, "sh", str(src), str(exe)]
|
||||
|
||||
|
||||
def _run_install(cmd: List[str], log: Optional[logging.Logger] = None) -> Optional[str]:
|
||||
if log:
|
||||
log.info("Linux update: %s", " ".join(cmd))
|
||||
proc = subprocess.run(
|
||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
timeout=_INSTALL_TIMEOUT_SEC,
|
||||
env=relaunch_env(),
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
return None
|
||||
err = (proc.stderr or proc.stdout or b"").decode("utf-8", "replace").strip()
|
||||
return err[-500:] or "exit code {0}".format(proc.returncode)
|
||||
|
||||
|
||||
def apply_update(
|
||||
kind: str, url: str, digest: str = "",
|
||||
log: Optional[logging.Logger] = None,
|
||||
) -> Optional[str]:
|
||||
"""Download and install the update.
|
||||
|
||||
Returns:
|
||||
Error text, or None on success.
|
||||
"""
|
||||
exe = exe_path()
|
||||
suffix = ASSET_SUFFIX[kind] or ".new"
|
||||
# with a writable directory download next to the binary and os.replace it
|
||||
in_place = kind == "binary" and os.access(str(exe.parent), os.W_OK)
|
||||
|
||||
if log:
|
||||
log.info("Linux update (%s): downloading %s", kind, url)
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="tgws-update-", dir=exe.parent if in_place else None) as directory:
|
||||
tmp = download_asset(url, Path(directory), suffix=suffix, digest=digest)
|
||||
if kind != "binary":
|
||||
return _run_install(install_cmd(kind, tmp), log=log)
|
||||
if in_place:
|
||||
os.chmod(str(tmp), 0o755)
|
||||
os.replace(str(tmp), str(exe))
|
||||
return None
|
||||
return _run_install(replace_binary_cmd(tmp, exe), log=log)
|
||||
except (OSError, ValueError, HTTPException, subprocess.SubprocessError) as exc:
|
||||
return str(exc)
|
||||
+30
-14
@@ -460,41 +460,57 @@ def check_ipv6_warning(show_info: Callable[[str, str], None]) -> None:
|
||||
|
||||
# update check
|
||||
|
||||
def maybe_notify_update(
|
||||
def check_update_async(
|
||||
cfg: dict,
|
||||
is_exiting: Callable[[], bool],
|
||||
ask_open: Callable[[str, str], bool],
|
||||
on_update: Callable[[str, str], None],
|
||||
) -> None:
|
||||
"""Check the latest release in the background.
|
||||
|
||||
on_update(version, release_page_url) is called from the worker thread
|
||||
when a newer version is available.
|
||||
"""
|
||||
if not cfg.get("check_updates", True):
|
||||
return
|
||||
|
||||
def _work():
|
||||
def _work() -> None:
|
||||
time.sleep(1.5)
|
||||
if is_exiting():
|
||||
return
|
||||
try:
|
||||
from utils.update_check import RELEASES_PAGE_URL, get_status, run_check
|
||||
import webbrowser
|
||||
|
||||
run_check(__version__)
|
||||
st = get_status()
|
||||
if not st.get("has_update"):
|
||||
if not st.get("has_update") or is_exiting():
|
||||
return
|
||||
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
|
||||
ver = st.get("latest") or "?"
|
||||
from ui.i18n import t
|
||||
|
||||
if ask_open(
|
||||
t("update.ask_open", version=ver),
|
||||
t("app.update_title"),
|
||||
):
|
||||
webbrowser.open(url)
|
||||
on_update(
|
||||
st.get("latest") or "?",
|
||||
(st.get("html_url") or "").strip() or RELEASES_PAGE_URL,
|
||||
)
|
||||
except Exception as exc:
|
||||
log.warning("Update check failed: %s", repr(exc))
|
||||
|
||||
threading.Thread(target=_work, daemon=True, name="update-check").start()
|
||||
|
||||
|
||||
def maybe_notify_update(
|
||||
cfg: dict,
|
||||
is_exiting: Callable[[], bool],
|
||||
ask_open: Callable[[str, str], bool],
|
||||
) -> None:
|
||||
"""Check for updates and offer to open the release page."""
|
||||
def _on_update(version: str, url: str) -> None:
|
||||
import webbrowser
|
||||
|
||||
from ui.i18n import t
|
||||
|
||||
if ask_open(t("update.ask_open", version=version), t("app.update_title")):
|
||||
webbrowser.open(url)
|
||||
|
||||
check_update_async(cfg, is_exiting, _on_update)
|
||||
|
||||
|
||||
# ctk thread (windows / linux)
|
||||
|
||||
_ctk_root: Any = None
|
||||
|
||||
+115
-278
@@ -1,324 +1,161 @@
|
||||
"""
|
||||
Проверка новой версии через GitHub Releases API
|
||||
|
||||
Ограничение частоты запросов: не чаще одного раза в час на машину (кэш в каталоге
|
||||
данных приложения). Поддерживается If-None-Match (ETag) для ответа 304.
|
||||
"""
|
||||
"""One startup check against GitHub Releases and verified asset downloads."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import tempfile
|
||||
from contextlib import suppress
|
||||
from http.client import HTTPException
|
||||
from itertools import zip_longest
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from urllib.error import HTTPError, URLError
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.request import Request
|
||||
|
||||
from proxy.utils import build_github_opener
|
||||
|
||||
REPO = "Flowseal/tg-ws-proxy"
|
||||
RELEASES_LATEST_API = f"https://api.github.com/repos/{REPO}/releases/latest"
|
||||
RELEASES_BY_TAG_API = f"https://api.github.com/repos/{REPO}/releases/tags/{{tag}}?t={{timestamp}}"
|
||||
RELEASES_PAGE_URL = f"https://github.com/{REPO}/releases/latest"
|
||||
|
||||
# Не чаще одного полного запроса к API в час (без учёта 304 с тем же ETag).
|
||||
_MIN_FETCH_INTERVAL_SEC = 3600.0
|
||||
|
||||
_SHA256 = re.compile(r"sha256:([0-9a-f]{64})", re.IGNORECASE)
|
||||
_state: Dict[str, Any] = {
|
||||
"checked": False,
|
||||
"has_update": False,
|
||||
"ahead_of_release": False,
|
||||
"latest": None,
|
||||
"html_url": None,
|
||||
"html_url": RELEASES_PAGE_URL,
|
||||
"error": None,
|
||||
"assets": [],
|
||||
}
|
||||
|
||||
|
||||
def _cache_file() -> Optional[Path]:
|
||||
try:
|
||||
from utils.tray_common import APP_DIR
|
||||
root = APP_DIR
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root / ".update_check_cache.json"
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _load_cache(path: Optional[Path]) -> Dict[str, Any]:
|
||||
if not path or not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _save_cache(path: Optional[Path], data: Dict[str, Any]) -> None:
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
path.write_text(json.dumps(data), encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _parse_version_tuple(s: str) -> tuple:
|
||||
s = (s or "").strip().lstrip("vV")
|
||||
if not s:
|
||||
return (0,)
|
||||
parts = []
|
||||
for seg in s.split("."):
|
||||
digits = next((seg[:i] for i, c in enumerate(seg) if not c.isdigit()), seg)
|
||||
if digits:
|
||||
try:
|
||||
parts.append(int(digits))
|
||||
except ValueError:
|
||||
parts.append(0)
|
||||
else:
|
||||
parts.append(0)
|
||||
return tuple(parts) if parts else (0,)
|
||||
for part in (s or "").strip().lstrip("vV").split("."):
|
||||
match = re.match(r"\d+", part)
|
||||
parts.append(int(match[0]) if match else 0)
|
||||
return tuple(parts)
|
||||
|
||||
|
||||
def _version_gt(a: str, b: str) -> bool:
|
||||
"""True, если версия a новее b (простое сравнение по сегментам)."""
|
||||
ta = _parse_version_tuple(a)
|
||||
tb = _parse_version_tuple(b)
|
||||
for x, y in zip_longest(ta, tb, fillvalue=0):
|
||||
if x > y:
|
||||
return True
|
||||
if x < y:
|
||||
return False
|
||||
for x, y in zip_longest(_parse_version_tuple(a), _parse_version_tuple(b), fillvalue=0):
|
||||
if x != y:
|
||||
return x > y
|
||||
return False
|
||||
|
||||
|
||||
def _apply_release_tag(
|
||||
tag: str, html_url: str, current_version: str,
|
||||
) -> None:
|
||||
global _state
|
||||
if not tag:
|
||||
_state["has_update"] = False
|
||||
_state["ahead_of_release"] = False
|
||||
_state["latest"] = None
|
||||
_state["html_url"] = html_url.strip() or RELEASES_PAGE_URL
|
||||
return
|
||||
latest_clean = tag.lstrip("vV")
|
||||
cur = (current_version or "").strip().lstrip("vV")
|
||||
_state["latest"] = latest_clean
|
||||
_state["html_url"] = html_url.strip() or RELEASES_PAGE_URL
|
||||
_state["has_update"] = _version_gt(latest_clean, cur)
|
||||
_state["ahead_of_release"] = bool(latest_clean) and _version_gt(
|
||||
cur, latest_clean
|
||||
)
|
||||
|
||||
|
||||
def fetch_latest_release(
|
||||
timeout: float = 12.0,
|
||||
etag: Optional[str] = None,
|
||||
) -> Tuple[Optional[dict], Optional[str], int]:
|
||||
"""
|
||||
GET releases/latest. Возвращает (data или None при 304, etag или None, HTTP-код).
|
||||
"""
|
||||
headers = {
|
||||
def fetch_latest_release(timeout: float = 12.0) -> dict:
|
||||
req = Request(RELEASES_LATEST_API, headers={
|
||||
"Accept": "application/vnd.github+json",
|
||||
"User-Agent": "tg-ws-proxy-update-check",
|
||||
}
|
||||
if etag:
|
||||
headers["If-None-Match"] = etag
|
||||
req = Request(
|
||||
RELEASES_LATEST_API,
|
||||
headers=headers,
|
||||
method="GET",
|
||||
)
|
||||
try:
|
||||
with build_github_opener().open(req, timeout=timeout) as resp:
|
||||
code = getattr(resp, "status", None) or resp.getcode()
|
||||
new_etag = resp.headers.get("ETag")
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
return json.loads(raw), new_etag, int(code)
|
||||
except HTTPError as e:
|
||||
if e.code == 304:
|
||||
hdrs = e.headers
|
||||
new_etag = hdrs.get("ETag") if hdrs else None
|
||||
return None, new_etag or etag, 304
|
||||
raise
|
||||
})
|
||||
with build_github_opener().open(req, timeout=timeout) as resp:
|
||||
return json.load(resp)
|
||||
|
||||
|
||||
def run_check(current_version: str) -> None:
|
||||
"""Запрашивает последний релиз и обновляет внутреннее состояние."""
|
||||
global _state
|
||||
_state["checked"] = True
|
||||
_state["error"] = None
|
||||
|
||||
cache_path = _cache_file()
|
||||
cache = _load_cache(cache_path)
|
||||
now = time.time()
|
||||
last_attempt = float(cache.get("last_attempt_at") or 0)
|
||||
|
||||
if last_attempt and (now - last_attempt) < _MIN_FETCH_INTERVAL_SEC:
|
||||
tag = (cache.get("tag_name") or "").strip()
|
||||
if tag:
|
||||
_apply_release_tag(tag, cache.get("html_url") or "", current_version)
|
||||
_state["assets"] = cache.get("assets") or []
|
||||
return
|
||||
err = cache.get("last_error")
|
||||
_state["error"] = (
|
||||
err if err else "Проверка обновлений отложена (интервал между запросами)."
|
||||
)
|
||||
_state["has_update"] = False
|
||||
_state["ahead_of_release"] = False
|
||||
_state["latest"] = None
|
||||
_state["html_url"] = RELEASES_PAGE_URL
|
||||
return
|
||||
|
||||
etag = (cache.get("etag") or "").strip() or None
|
||||
try:
|
||||
data, new_etag, code = fetch_latest_release(etag=etag)
|
||||
cache["last_attempt_at"] = now
|
||||
if code == 304:
|
||||
tag = (cache.get("tag_name") or "").strip()
|
||||
url = (cache.get("html_url") or "").strip() or RELEASES_PAGE_URL
|
||||
_apply_release_tag(tag, url, current_version)
|
||||
_state["assets"] = cache.get("assets") or []
|
||||
if new_etag:
|
||||
cache["etag"] = new_etag
|
||||
_save_cache(cache_path, cache)
|
||||
return
|
||||
|
||||
assert data is not None
|
||||
tag = (data.get("tag_name") or "").strip()
|
||||
html_url = (data.get("html_url") or "").strip() or RELEASES_PAGE_URL
|
||||
if not tag:
|
||||
_state["has_update"] = False
|
||||
_state["ahead_of_release"] = False
|
||||
_state["latest"] = None
|
||||
_state["html_url"] = html_url
|
||||
else:
|
||||
_apply_release_tag(tag, html_url, current_version)
|
||||
if new_etag:
|
||||
cache["etag"] = new_etag
|
||||
cache["tag_name"] = tag
|
||||
cache["html_url"] = html_url
|
||||
assets = [
|
||||
{"name": a.get("name", ""), "url": a.get("browser_download_url", ""), "digest": a.get("digest", "")}
|
||||
for a in (data.get("assets") or [])
|
||||
if a.get("name") and a.get("browser_download_url")
|
||||
]
|
||||
_state["assets"] = assets
|
||||
cache["assets"] = assets
|
||||
cache.pop("last_error", None)
|
||||
_save_cache(cache_path, cache)
|
||||
except (HTTPError, URLError, OSError, TimeoutError, ValueError, json.JSONDecodeError) as e:
|
||||
cache["last_attempt_at"] = now
|
||||
msg = str(e)
|
||||
if isinstance(e, HTTPError) and e.code == 403:
|
||||
msg = (
|
||||
"GitHub API вернул 403 (лимит или доступ). Повторите позже."
|
||||
)
|
||||
cache["last_error"] = msg
|
||||
_save_cache(cache_path, cache)
|
||||
_state["error"] = msg
|
||||
_state["has_update"] = False
|
||||
_state["ahead_of_release"] = False
|
||||
_state["latest"] = None
|
||||
_state["html_url"] = RELEASES_PAGE_URL
|
||||
|
||||
|
||||
def fetch_release_by_tag(
|
||||
tag: str, timeout: float = 12.0,
|
||||
) -> Tuple[Optional[dict], int]:
|
||||
if not tag:
|
||||
return None, 0
|
||||
headers = {
|
||||
"Accept": "application/vnd.github+json",
|
||||
"User-Agent": "tg-ws-proxy-update-check",
|
||||
}
|
||||
req = Request(
|
||||
RELEASES_BY_TAG_API.format(tag=tag, timestamp=int(time.time())),
|
||||
headers=headers,
|
||||
method="GET",
|
||||
)
|
||||
try:
|
||||
with build_github_opener().open(req, timeout=timeout) as resp:
|
||||
code = getattr(resp, "status", None) or resp.getcode()
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
return json.loads(raw), int(code)
|
||||
except HTTPError as e:
|
||||
if e.code in [304, 404]:
|
||||
return None, e.code
|
||||
raise
|
||||
|
||||
|
||||
def _extract_assets(data: Optional[dict]) -> list:
|
||||
if not data:
|
||||
return []
|
||||
def _extract_assets(data: dict) -> list:
|
||||
assets = (data or {}).get("assets") or []
|
||||
if not isinstance(assets, list):
|
||||
raise ValueError("Invalid release assets from GitHub")
|
||||
return [
|
||||
{"name": a.get("name", ""), "url": a.get("browser_download_url", ""), "digest": a.get("digest", "")}
|
||||
for a in (data.get("assets") or [])
|
||||
if a.get("name") and a.get("browser_download_url")
|
||||
{"name": a["name"], "url": a["browser_download_url"],
|
||||
"digest": a.get("digest") if isinstance(a.get("digest"), str) else ""}
|
||||
for a in assets
|
||||
if isinstance(a, dict) and isinstance(a.get("name"), str) and a["name"]
|
||||
and isinstance(a.get("browser_download_url"), str) and a["browser_download_url"]
|
||||
]
|
||||
|
||||
|
||||
def get_status() -> Dict[str, Any]:
|
||||
"""Снимок состояния после run_check (для подписей в настройках)."""
|
||||
return dict(_state)
|
||||
|
||||
|
||||
def get_update_asset(exe_path: Path, current_version: str) -> Optional[Tuple[str, str]]:
|
||||
new_assets = _state.get("assets") or []
|
||||
if not new_assets:
|
||||
return None
|
||||
|
||||
target_name = None
|
||||
|
||||
# SHA256 match
|
||||
def run_check(current_version: str) -> None:
|
||||
"""Publish the complete result at once; no persistent cache or error cooldown."""
|
||||
global _state
|
||||
state = {
|
||||
"checked": True, "has_update": False, "ahead_of_release": False,
|
||||
"latest": None, "html_url": RELEASES_PAGE_URL, "error": None, "assets": [],
|
||||
}
|
||||
try:
|
||||
import hashlib
|
||||
data, code = fetch_release_by_tag(f"v{current_version}")
|
||||
if code == 200 and data:
|
||||
cur_assets = _extract_assets(data)
|
||||
if cur_assets:
|
||||
h = hashlib.sha256()
|
||||
with open(exe_path, "rb") as f:
|
||||
while True:
|
||||
chunk = f.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
h.update(chunk)
|
||||
exe_sha = h.hexdigest().lower()
|
||||
for a in cur_assets:
|
||||
d = (a.get("digest") or "").lower()
|
||||
if d.startswith("sha256:") and d[7:] == exe_sha:
|
||||
target_name = a["name"]
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
data = fetch_latest_release()
|
||||
if not isinstance(data, dict) or not isinstance(data.get("tag_name"), str):
|
||||
raise ValueError("Invalid release from GitHub")
|
||||
latest = data["tag_name"].strip().lstrip("vV")
|
||||
if not latest or not latest[0].isdigit():
|
||||
raise ValueError("Invalid release version from GitHub")
|
||||
state.update(
|
||||
latest=latest,
|
||||
has_update=_version_gt(latest, current_version),
|
||||
ahead_of_release=_version_gt(current_version, latest),
|
||||
html_url=data.get("html_url") or RELEASES_PAGE_URL,
|
||||
assets=_extract_assets(data),
|
||||
)
|
||||
except (OSError, ValueError, HTTPException) as exc:
|
||||
state["error"] = str(exc) or type(exc).__name__
|
||||
logging.getLogger(__name__).warning("Update check failed: %s", exc)
|
||||
_state = state
|
||||
|
||||
# Fallback
|
||||
if not target_name or target_name not in [a.get("name") for a in new_assets]:
|
||||
import platform
|
||||
import struct
|
||||
|
||||
is_64 = struct.calcsize("P") * 8 == 64
|
||||
machine = platform.machine().lower()
|
||||
is_arm64 = machine in ("arm64", "aarch64")
|
||||
def download_asset(
|
||||
url: str, dest_dir: Path, suffix: str = ".tmp", digest: str = "",
|
||||
timeout: float = 30.0,
|
||||
) -> Path:
|
||||
"""Return a temporary file only after its mandatory SHA-256 has matched."""
|
||||
expected = _SHA256.fullmatch(digest if isinstance(digest, str) else "")
|
||||
if not expected:
|
||||
raise ValueError("The release asset has no valid SHA-256 checksum")
|
||||
|
||||
try:
|
||||
is_modern = sys.getwindowsversion().major >= 10
|
||||
except Exception:
|
||||
is_modern = True
|
||||
fd, name = tempfile.mkstemp(dir=str(dest_dir), suffix=suffix, prefix="TgWsProxy_tmp_")
|
||||
path = Path(name)
|
||||
try:
|
||||
sha = hashlib.sha256()
|
||||
with os.fdopen(fd, "wb") as out, build_github_opener().open(url, timeout=timeout) as resp:
|
||||
while True:
|
||||
chunk = resp.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
out.write(chunk)
|
||||
sha.update(chunk)
|
||||
if sha.hexdigest() != expected[1].lower():
|
||||
raise OSError("SHA-256 checksum does not match the release asset")
|
||||
return path
|
||||
except BaseException:
|
||||
with suppress(OSError):
|
||||
path.unlink()
|
||||
raise
|
||||
|
||||
if is_arm64:
|
||||
target_name = "TgWsProxy_windows_arm64.exe"
|
||||
elif is_modern:
|
||||
target_name = "TgWsProxy_windows.exe"
|
||||
elif is_64:
|
||||
target_name = "TgWsProxy_windows_7_64bit.exe"
|
||||
else:
|
||||
target_name = "TgWsProxy_windows_7_32bit.exe"
|
||||
|
||||
for a in new_assets:
|
||||
if a.get("name") == target_name:
|
||||
return a["url"], a["name"]
|
||||
|
||||
def find_asset(name: str) -> Optional[Dict[str, str]]:
|
||||
"""Find an asset suitable for automatic installation."""
|
||||
for asset in _state["assets"]:
|
||||
if asset["name"] == name and asset["url"] and _SHA256.fullmatch(asset["digest"]):
|
||||
return dict(asset)
|
||||
return None
|
||||
|
||||
|
||||
def get_status() -> Dict[str, Any]:
|
||||
state = _state
|
||||
return {**state, "assets": [dict(a) for a in state["assets"]]}
|
||||
|
||||
|
||||
def relaunch_env() -> Dict[str, str]:
|
||||
"""Use system libraries and unpack a fresh bundle with PyInstaller 5 or 6."""
|
||||
env = {k: v for k, v in os.environ.items() if not k.startswith(("_PYI_", "_MEIPASS"))}
|
||||
mei = getattr(sys, "_MEIPASS", None)
|
||||
if mei:
|
||||
env["PYINSTALLER_RESET_ENVIRONMENT"] = "1"
|
||||
for var in ("PATH", "LD_LIBRARY_PATH", "GI_TYPELIB_PATH", "GTK_PATH",
|
||||
"GST_PLUGIN_PATH", "GIO_MODULE_DIR"):
|
||||
original = env.pop(var + "_ORIG", None)
|
||||
if original is not None:
|
||||
env[var] = original
|
||||
elif mei and env.get(var):
|
||||
bundle = os.path.normcase(os.path.abspath(mei))
|
||||
kept = [p for p in env[var].split(os.pathsep)
|
||||
if not (os.path.normcase(os.path.abspath(p)) + os.sep).startswith(bundle + os.sep)]
|
||||
if kept:
|
||||
env[var] = os.pathsep.join(kept)
|
||||
else:
|
||||
env.pop(var, None)
|
||||
return env
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Windows build selection and executable replacement with rollback."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import subprocess
|
||||
import sys
|
||||
import sysconfig
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from utils.update_check import download_asset, relaunch_env
|
||||
|
||||
|
||||
def asset_name() -> Optional[str]:
|
||||
# Use the running build, including x86/x64 emulation and renamed executables.
|
||||
target = sysconfig.get_platform()
|
||||
if target == "win-arm64":
|
||||
return "TgWsProxy_windows_arm64.exe"
|
||||
if target == "win32":
|
||||
return "TgWsProxy_windows_7_32bit.exe"
|
||||
if target == "win-amd64":
|
||||
return "TgWsProxy_windows_7_64bit.exe" if sys.version_info < (3, 9) else "TgWsProxy_windows.exe"
|
||||
return None
|
||||
|
||||
|
||||
def apply_update(asset: dict, before_restart: Callable[[], None]) -> None:
|
||||
"""Verify, replace and launch; keep the old executable if any step fails."""
|
||||
exe = Path(sys.executable)
|
||||
backup = exe.with_name(exe.stem + "_oldtgws.exe")
|
||||
downloaded = download_asset(asset["url"], exe.parent, digest=asset["digest"])
|
||||
try:
|
||||
backup.unlink(missing_ok=True)
|
||||
exe.rename(backup)
|
||||
try:
|
||||
downloaded.replace(exe)
|
||||
before_restart()
|
||||
bundle = getattr(sys, "_MEIPASS", None)
|
||||
if bundle:
|
||||
ctypes.windll.kernel32.SetDllDirectoryW(None)
|
||||
try:
|
||||
subprocess.Popen(
|
||||
[str(exe), *sys.argv[1:]], env=relaunch_env(),
|
||||
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
|
||||
)
|
||||
finally:
|
||||
if bundle:
|
||||
ctypes.windll.kernel32.SetDllDirectoryW(ctypes.c_wchar_p(bundle))
|
||||
except BaseException:
|
||||
backup.replace(exe)
|
||||
raise
|
||||
finally:
|
||||
with suppress(OSError):
|
||||
downloaded.unlink(missing_ok=True)
|
||||
+45
-131
@@ -2,17 +2,14 @@ from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import webbrowser
|
||||
import winreg
|
||||
import tempfile
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from proxy.utils import build_github_opener
|
||||
|
||||
try:
|
||||
import pyperclip
|
||||
@@ -42,14 +39,13 @@ from utils.win32_theme import (
|
||||
)
|
||||
from utils.tray_common import (
|
||||
APP_NAME, DEFAULT_CONFIG, FIRST_RUN_MARKER, IS_FROZEN, LOG_FILE,
|
||||
acquire_lock, bootstrap, check_ipv6_warning, ctk_run_dialog,
|
||||
acquire_lock, bootstrap, check_ipv6_warning, check_update_async, ctk_run_dialog,
|
||||
ensure_ctk_thread, ensure_dirs, load_config, load_icon, log,
|
||||
quit_ctk, release_lock, restart_proxy,
|
||||
save_config, start_proxy, stop_proxy, tg_proxy_url,
|
||||
)
|
||||
from utils.update_check import (
|
||||
get_status, get_update_asset, run_check, RELEASES_PAGE_URL,
|
||||
)
|
||||
from utils.update_check import find_asset, get_status, RELEASES_PAGE_URL
|
||||
from utils.windows_update import apply_update, asset_name
|
||||
from ui.ctk_tray_ui import (
|
||||
install_tray_config_buttons, install_tray_config_form,
|
||||
populate_first_run_window, tray_settings_scroll_and_footer,
|
||||
@@ -128,14 +124,16 @@ def _ask_yes_no(text: str, title: Optional[str] = None) -> bool:
|
||||
|
||||
|
||||
def update_ctk_form(
|
||||
text: str, title: Optional[str] = None, download_url: Optional[str] = None,
|
||||
text: str, title: Optional[str] = None, asset: Optional[dict] = None,
|
||||
release_url: Optional[str] = None,
|
||||
) -> str:
|
||||
title = title or t("app.name")
|
||||
if ctk is None or not ensure_ctk_thread(ctk, _config.get("appearance", "auto")):
|
||||
if not asset:
|
||||
return "open" if _ask_yes_no(t("update.ask_open", version=get_status()["latest"]), title) else "close"
|
||||
result = _u32.MessageBoxW(None, text, title, _MB_YESNOCANCEL_Q)
|
||||
if result == _IDYES:
|
||||
return "update"
|
||||
_perform_update(asset)
|
||||
if result == _IDNO:
|
||||
return "open"
|
||||
return "close"
|
||||
@@ -147,10 +145,10 @@ def update_ctk_form(
|
||||
root = create_ctk_toplevel(
|
||||
ctk,
|
||||
title=title,
|
||||
width=310 if IS_FROZEN else 210,
|
||||
height=130 if IS_FROZEN else 100,
|
||||
width=310 if asset else 210,
|
||||
height=130 if asset else 100,
|
||||
theme=theme,
|
||||
topmost=False,
|
||||
topmost=True,
|
||||
after_create=lambda r: r.iconbitmap(ICON_PATH),
|
||||
)
|
||||
frame = main_content_frame(ctk, root, theme, padx=16, pady=14)
|
||||
@@ -185,21 +183,16 @@ def update_ctk_form(
|
||||
done.set()
|
||||
|
||||
def _on_update() -> None:
|
||||
if not download_url:
|
||||
if release_url:
|
||||
webbrowser.open(release_url)
|
||||
_close_with("open")
|
||||
return
|
||||
for b in btns:
|
||||
b.configure(state="disabled")
|
||||
root.protocol("WM_DELETE_WINDOW", lambda: None)
|
||||
def _run():
|
||||
_perform_update(download_url, set_status=_set_status)
|
||||
_perform_update(asset, set_status=_set_status)
|
||||
root.after(0, lambda: [b.configure(state="normal") for b in btns])
|
||||
root.after(0, lambda: root.protocol("WM_DELETE_WINDOW", lambda: _close_with("close")))
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
|
||||
if IS_FROZEN:
|
||||
if asset:
|
||||
btn_upd = ctk.CTkButton(
|
||||
row, text=t("button.update"), width=88, height=34,
|
||||
font=(theme.ui_font_family, 13), command=_on_update,
|
||||
@@ -228,97 +221,37 @@ def update_ctk_form(
|
||||
return result["value"]
|
||||
|
||||
|
||||
def _perform_update(download_url: str, set_status=None) -> None:
|
||||
def _perform_update(asset: dict, set_status=None) -> None:
|
||||
restarting = False
|
||||
|
||||
def _step(msg: str) -> None:
|
||||
log.info("Update: %s", msg)
|
||||
if set_status:
|
||||
set_status(msg)
|
||||
time.sleep(0.8)
|
||||
|
||||
def _err(msg: str) -> None:
|
||||
log.error("Update error: %s", msg)
|
||||
if set_status:
|
||||
set_status(f"{t('update.error', msg=msg)}")
|
||||
else:
|
||||
_show_error(msg)
|
||||
def _before_restart() -> None:
|
||||
nonlocal restarting
|
||||
_step(t("update.restarting"))
|
||||
restarting = True
|
||||
stop_proxy()
|
||||
release_lock()
|
||||
_release_win_mutex()
|
||||
|
||||
_step(t("update.downloading"))
|
||||
cur_exe = Path(sys.executable)
|
||||
old_exe = cur_exe.with_name(cur_exe.stem + "_oldtgws.exe")
|
||||
tmp_path = None
|
||||
try:
|
||||
fd, tmp_name = tempfile.mkstemp(dir=cur_exe.parent, suffix=".tmp")
|
||||
os.close(fd)
|
||||
tmp_path = Path(tmp_name)
|
||||
log.info("Downloading update from %s", download_url)
|
||||
opener = build_github_opener()
|
||||
with opener.open(download_url) as _resp:
|
||||
with open(str(tmp_path), "wb") as _fout:
|
||||
while True:
|
||||
_chunk = _resp.read(65536)
|
||||
if not _chunk:
|
||||
break
|
||||
_fout.write(_chunk)
|
||||
apply_update(asset, _before_restart)
|
||||
except Exception as exc:
|
||||
_err(t("update.download_fail", error=exc))
|
||||
if tmp_path:
|
||||
try:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
log.exception("Update failed")
|
||||
if restarting:
|
||||
mutex = _acquire_win_mutex()
|
||||
if mutex is True or mutex is None and acquire_lock():
|
||||
start_proxy(_config, _show_error)
|
||||
msg = t("update.error", msg=exc)
|
||||
if set_status:
|
||||
set_status(msg)
|
||||
else:
|
||||
_show_error(msg)
|
||||
return
|
||||
|
||||
_step(t("update.replacing"))
|
||||
try:
|
||||
if old_exe.exists():
|
||||
old_exe.unlink()
|
||||
cur_exe.rename(old_exe)
|
||||
except Exception as exc:
|
||||
_err(t("update.rename_fail", error=exc))
|
||||
try:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
tmp_path.rename(cur_exe)
|
||||
except Exception as exc:
|
||||
_err(t("update.move_fail", error=exc))
|
||||
try:
|
||||
old_exe.rename(cur_exe)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return
|
||||
|
||||
_step(t("update.restarting"))
|
||||
_release_win_mutex()
|
||||
stop_proxy()
|
||||
|
||||
# Don't reuse existing _MEI* dir
|
||||
env = os.environ.copy()
|
||||
for _k in [k for k in env if k.startswith("_PYI_") or k == "_MEIPASS"]:
|
||||
del env[_k]
|
||||
if hasattr(sys, "_MEIPASS"):
|
||||
_mei = os.path.normcase(sys._MEIPASS.rstrip("\\/"))
|
||||
env["PATH"] = os.pathsep.join(
|
||||
p for p in env.get("PATH", "").split(os.pathsep)
|
||||
if os.path.normcase(p.rstrip("\\/")) != _mei
|
||||
)
|
||||
|
||||
try:
|
||||
subprocess.Popen(
|
||||
[str(cur_exe)],
|
||||
env=env,
|
||||
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
|
||||
)
|
||||
except Exception as exc:
|
||||
log.error("Failed to launch updated exe: %s", exc)
|
||||
time.sleep(0.5)
|
||||
os._exit(0)
|
||||
|
||||
|
||||
@@ -339,13 +272,15 @@ def _trigger_update_flow(icon=None, item=None) -> None:
|
||||
st = get_status()
|
||||
if not st.get("has_update"):
|
||||
return
|
||||
if _tray_icon is not None:
|
||||
_tray_icon.menu = _build_menu()
|
||||
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
|
||||
ver = st.get("latest") or "?"
|
||||
asset = get_update_asset(Path(sys.executable), __version__) if IS_FROZEN else None
|
||||
asset = find_asset(asset_name()) if IS_FROZEN else None
|
||||
|
||||
choice = update_ctk_form(
|
||||
t("update.available", version=ver),
|
||||
download_url=asset[0] if asset else None,
|
||||
asset=asset,
|
||||
release_url=url,
|
||||
)
|
||||
if choice == "open":
|
||||
@@ -358,27 +293,6 @@ def _trigger_update_flow(icon=None, item=None) -> None:
|
||||
threading.Thread(target=_show, daemon=True, name="manual-update").start()
|
||||
|
||||
|
||||
def _maybe_do_update(cfg: dict, is_exiting) -> None:
|
||||
if not cfg.get("check_updates", True):
|
||||
return
|
||||
|
||||
def _work():
|
||||
time.sleep(1.5)
|
||||
if is_exiting():
|
||||
return
|
||||
try:
|
||||
run_check(__version__)
|
||||
if _tray_icon is not None:
|
||||
_tray_icon.menu = _build_menu()
|
||||
if is_exiting():
|
||||
return
|
||||
_trigger_update_flow()
|
||||
except Exception as exc:
|
||||
log.warning("Update check failed: %s", repr(exc))
|
||||
|
||||
threading.Thread(target=_work, daemon=True, name="update-check").start()
|
||||
|
||||
|
||||
# autostart (registry)
|
||||
|
||||
_RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
|
||||
@@ -537,6 +451,7 @@ def _edit_config_dialog() -> None:
|
||||
restart=lambda config: restart_proxy(config, _show_error),
|
||||
include_autostart=_supports_autostart(),
|
||||
apply_autostart=set_autostart_enabled if _supports_autostart() else None,
|
||||
read_autostart=is_autostart_enabled if _supports_autostart() else None,
|
||||
)
|
||||
|
||||
root.protocol("WM_DELETE_WINDOW", dialog.cancel)
|
||||
@@ -633,7 +548,7 @@ def run_tray() -> None:
|
||||
return
|
||||
|
||||
start_proxy(_config, _show_error)
|
||||
_maybe_do_update(_config, lambda: _exiting)
|
||||
check_update_async(_config, lambda: _exiting, lambda version, url: _trigger_update_flow())
|
||||
_show_first_run()
|
||||
check_ipv6_warning(_show_info)
|
||||
|
||||
@@ -652,14 +567,13 @@ def main() -> None:
|
||||
|
||||
if IS_FROZEN:
|
||||
def _cleanup_old_exes():
|
||||
exe_dir = Path(sys.executable).parent
|
||||
time.sleep(3)
|
||||
for _f in exe_dir.glob("*_oldtgws.exe"):
|
||||
try:
|
||||
_f.unlink()
|
||||
log.info("Deleted leftover: %s", _f)
|
||||
except OSError:
|
||||
pass
|
||||
exe = Path(sys.executable)
|
||||
backup = exe.with_name(exe.stem + "_oldtgws.exe")
|
||||
try:
|
||||
backup.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
threading.Thread(target=_cleanup_old_exes, daemon=True, name="cleanup-old").start()
|
||||
|
||||
try:
|
||||
|
||||
Reference in new issue
Block a user