mirror of
https://github.com/Flowseal/tg-ws-proxy.git
synced 2026-06-23 15:01:08 +03:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fed772049b | |||
| 91d39a5ebe | |||
| 5cbac657dc | |||
| ee6c34e065 | |||
| ce6a456bd1 | |||
| 5bc5001c4d | |||
| 2afd80825b |
@@ -13,3 +13,8 @@ khgrre.com
|
||||
ulihssf.com
|
||||
tmhqsdqmfpmk.com
|
||||
xwuwoqbm.com
|
||||
orgcnunpj.com
|
||||
zhkuldz.com
|
||||
zypoljnslxa.com
|
||||
efabnxaowuzs.com
|
||||
zaftuzsftqdq.com
|
||||
|
||||
+30
-36
@@ -272,30 +272,10 @@ jobs:
|
||||
python3.12 -m pip install .
|
||||
python3.12 -m pip install pyinstaller==6.13.0
|
||||
|
||||
- name: Create macOS icon from ICO
|
||||
- name: Create macOS icon
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3.12 - <<'PY'
|
||||
from PIL import Image
|
||||
|
||||
image = Image.open('icon.ico')
|
||||
image = image.resize((1024, 1024), Image.LANCZOS)
|
||||
image.save('icon_1024.png', 'PNG')
|
||||
PY
|
||||
|
||||
mkdir -p icon.iconset
|
||||
sips -z 16 16 icon_1024.png --out icon.iconset/icon_16x16.png
|
||||
sips -z 32 32 icon_1024.png --out icon.iconset/icon_16x16@2x.png
|
||||
sips -z 32 32 icon_1024.png --out icon.iconset/icon_32x32.png
|
||||
sips -z 64 64 icon_1024.png --out icon.iconset/icon_32x32@2x.png
|
||||
sips -z 128 128 icon_1024.png --out icon.iconset/icon_128x128.png
|
||||
sips -z 256 256 icon_1024.png --out icon.iconset/icon_128x128@2x.png
|
||||
sips -z 256 256 icon_1024.png --out icon.iconset/icon_256x256.png
|
||||
sips -z 512 512 icon_1024.png --out icon.iconset/icon_256x256@2x.png
|
||||
sips -z 512 512 icon_1024.png --out icon.iconset/icon_512x512.png
|
||||
sips -z 1024 1024 icon_1024.png --out icon.iconset/icon_512x512@2x.png
|
||||
iconutil -c icns icon.iconset -o icon.icns
|
||||
rm -rf icon.iconset icon_1024.png
|
||||
python3.12 macos.py --render-app-icon icon.icns
|
||||
|
||||
- name: Build app with PyInstaller
|
||||
run: python3.12 -m PyInstaller packaging/macos.spec --noconfirm
|
||||
@@ -303,6 +283,11 @@ jobs:
|
||||
- name: Validate universal2 app bundle
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ICON_FILE="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIconFile' \
|
||||
'dist/TG WS Proxy.app/Contents/Info.plist')"
|
||||
test -n "$ICON_FILE"
|
||||
test -f "dist/TG WS Proxy.app/Contents/Resources/$ICON_FILE"
|
||||
|
||||
found=0
|
||||
while IFS= read -r -d '' file; do
|
||||
if file "$file" | grep -q "Mach-O"; then
|
||||
@@ -326,22 +311,31 @@ jobs:
|
||||
- name: Create DMG
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APP_NAME="TG WS Proxy"
|
||||
DMG_TEMP="dist/dmg_temp"
|
||||
|
||||
rm -rf "$DMG_TEMP"
|
||||
mkdir -p "$DMG_TEMP"
|
||||
cp -R "dist/${APP_NAME}.app" "$DMG_TEMP/"
|
||||
ln -s /Applications "$DMG_TEMP/Applications"
|
||||
|
||||
hdiutil create \
|
||||
-volname "$APP_NAME" \
|
||||
-srcfolder "$DMG_TEMP" \
|
||||
-ov \
|
||||
-format UDZO \
|
||||
packaging/dmg/build_dmg.sh \
|
||||
"dist/TG WS Proxy.app" \
|
||||
"TG WS Proxy" \
|
||||
"dist/TgWsProxy_macos_universal.dmg"
|
||||
|
||||
rm -rf "$DMG_TEMP"
|
||||
- name: Validate DMG
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for DMG in "dist/TgWsProxy_macos_universal.dmg"; do
|
||||
MOUNT_DIR="$(mktemp -d)"
|
||||
DEVICE="$(hdiutil attach \
|
||||
-readonly \
|
||||
-nobrowse \
|
||||
-mountpoint "$MOUNT_DIR" \
|
||||
"$DMG" \
|
||||
| awk '/^\/dev\// { print $1; exit }')"
|
||||
|
||||
test -d "$MOUNT_DIR/TG WS Proxy.app"
|
||||
test -L "$MOUNT_DIR/Applications"
|
||||
test "$(readlink "$MOUNT_DIR/Applications")" = "/Applications"
|
||||
test -f "$MOUNT_DIR/.background/background.tiff"
|
||||
test -f "$MOUNT_DIR/.DS_Store"
|
||||
hdiutil detach "$DEVICE"
|
||||
rmdir "$MOUNT_DIR"
|
||||
done
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@
|
||||
- [Fake TLS + upstream в Nginx](./FakeTlsNginx.md)
|
||||
- [Файлы конфигурации Tray-приложения](./TrayConfig.md)
|
||||
- [Установка из исходников](./BuildFromSource.md)
|
||||
- [Руководство для контрибьюторов](../CONTRIBUTING.md)
|
||||
- [Руководство для контрибьюторов](./CONTRIBUTING.md)
|
||||
|
||||
## Windows: быстрый вход
|
||||
|
||||
|
||||
@@ -43,6 +43,10 @@
|
||||
- **Порт:** `1443` (или переопределенный вами)
|
||||
- **Secret:** из настроек или логов
|
||||
|
||||
## Портативный режим
|
||||
Портативный режим автоматически включается, если рядом с исполняемым файлом есть папка с названием `TgWsProxy_data`.
|
||||
Либо можно принудительно включить портативный режим (который сам создаст папку), запустив исполняемый файл с параметром `--portable`.
|
||||
|
||||
## Установка из исходников
|
||||
|
||||
Подробная инструкция: [BuildFromSource.md](./BuildFromSource.md)
|
||||
|
||||
@@ -9,16 +9,53 @@ import webbrowser
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
import rumps
|
||||
except ImportError:
|
||||
rumps = None
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
except ImportError:
|
||||
Image = ImageDraw = ImageFont = None
|
||||
|
||||
|
||||
def render_app_icon(size: int):
|
||||
scale = size / 1024
|
||||
image = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(image)
|
||||
outer = tuple(round(value * scale) for value in (92, 92, 932, 932))
|
||||
draw.ellipse(outer, fill=(0, 151, 221, 255))
|
||||
try:
|
||||
font = ImageFont.truetype(
|
||||
"/System/Library/Fonts/Helvetica.ttc",
|
||||
round(430 * scale),
|
||||
)
|
||||
except Exception:
|
||||
font = ImageFont.load_default()
|
||||
box = draw.textbbox((0, 0), "T", font=font)
|
||||
width = box[2] - box[0]
|
||||
height = box[3] - box[1]
|
||||
draw.text(
|
||||
(
|
||||
(size - width) / 2 - box[0],
|
||||
(size - height) / 2 - box[1] - round(10 * scale),
|
||||
),
|
||||
"T",
|
||||
font=font,
|
||||
fill=(255, 255, 255, 255),
|
||||
)
|
||||
return image
|
||||
|
||||
|
||||
if __name__ == "__main__" and len(sys.argv) > 1 and sys.argv[1] == "--render-app-icon":
|
||||
if Image is None:
|
||||
raise SystemExit("Pillow is required to render the macOS app icon")
|
||||
output_path = sys.argv[2] if len(sys.argv) > 2 else "icon.icns"
|
||||
render_app_icon(1024).save(output_path, format="ICNS")
|
||||
raise SystemExit(0)
|
||||
|
||||
|
||||
try:
|
||||
import rumps
|
||||
except ImportError:
|
||||
rumps = None
|
||||
|
||||
try:
|
||||
import pyperclip
|
||||
except ImportError:
|
||||
@@ -144,26 +181,10 @@ def _ask_cfworker_domain(default: str) -> Optional[str]:
|
||||
def _make_menubar_icon(size: int = 44):
|
||||
if Image is None:
|
||||
return None
|
||||
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
margin = size // 11
|
||||
draw.ellipse([margin, margin, size - margin, size - margin], fill=(0, 0, 0, 255))
|
||||
try:
|
||||
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", size=int(size * 0.55))
|
||||
except Exception:
|
||||
font = ImageFont.load_default()
|
||||
bbox = draw.textbbox((0, 0), "T", font=font)
|
||||
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||
draw.text(
|
||||
((size - tw) // 2 - bbox[0], (size - th) // 2 - bbox[1]),
|
||||
"T", fill=(255, 255, 255, 255), font=font,
|
||||
)
|
||||
return img
|
||||
return render_app_icon(size)
|
||||
|
||||
|
||||
def _ensure_menubar_icon() -> None:
|
||||
if MENUBAR_ICON_PATH.exists():
|
||||
return
|
||||
ensure_dirs()
|
||||
img = _make_menubar_icon(44)
|
||||
if img:
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Executable
+93
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
APP_PATH="${1:?Usage: build_dmg.sh <App.app> <Volume Name> <output.dmg> [assets_dir]}"
|
||||
VOL_NAME="${2:?missing volume name}"
|
||||
OUT_DMG="${3:?missing output dmg path}"
|
||||
ASSETS_DIR="${4:-$(cd "$(dirname "${BASH_SOURCE[0]}")/assets" && pwd)}"
|
||||
|
||||
WIN_W=660
|
||||
WIN_H=440
|
||||
ICON_SIZE=128
|
||||
APP_X=145
|
||||
APPS_X=515
|
||||
ICON_Y=220
|
||||
|
||||
APP_NAME="$(basename "$APP_PATH")"
|
||||
WORK="$(mktemp -d)"
|
||||
STAGE="$WORK/stage"
|
||||
RW_DMG="$WORK/rw.dmg"
|
||||
MOUNT="/Volumes/$VOL_NAME"
|
||||
DEVICE=""
|
||||
|
||||
cleanup() {
|
||||
if [ -n "$DEVICE" ]; then
|
||||
hdiutil detach "$DEVICE" -force >/dev/null 2>&1 || true
|
||||
fi
|
||||
rm -rf "$WORK"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$STAGE/.background"
|
||||
cp -R "$APP_PATH" "$STAGE/"
|
||||
ln -s /Applications "$STAGE/Applications"
|
||||
|
||||
tiffutil -cathidpicheck \
|
||||
"$ASSETS_DIR/background-light.png" \
|
||||
"$ASSETS_DIR/background-light@2x.png" \
|
||||
-out "$STAGE/.background/background.tiff"
|
||||
|
||||
hdiutil create \
|
||||
-volname "$VOL_NAME" \
|
||||
-srcfolder "$STAGE" \
|
||||
-fs HFS+ \
|
||||
-format UDRW \
|
||||
-ov \
|
||||
"$RW_DMG"
|
||||
|
||||
DEVICE="$(hdiutil attach \
|
||||
-readwrite \
|
||||
-noverify \
|
||||
-noautoopen \
|
||||
-mountpoint "$MOUNT" \
|
||||
"$RW_DMG" \
|
||||
| awk '/^\/dev\// { print $1; exit }')"
|
||||
test -n "$DEVICE"
|
||||
test -d "$MOUNT/$APP_NAME"
|
||||
|
||||
sleep 2
|
||||
|
||||
osascript <<APPLESCRIPT
|
||||
tell application "Finder"
|
||||
tell disk "$VOL_NAME"
|
||||
open
|
||||
set current view of container window to icon view
|
||||
set toolbar visible of container window to false
|
||||
set statusbar visible of container window to false
|
||||
set the bounds of container window to {200, 140, 200 + $WIN_W, 140 + $WIN_H}
|
||||
set theViewOptions to the icon view options of container window
|
||||
set arrangement of theViewOptions to not arranged
|
||||
set icon size of theViewOptions to $ICON_SIZE
|
||||
set text size of theViewOptions to 13
|
||||
set background picture of theViewOptions to file ".background:background.tiff"
|
||||
set position of item "$APP_NAME" of container window to {$APP_X, $ICON_Y}
|
||||
set position of item "Applications" of container window to {$APPS_X, $ICON_Y}
|
||||
close
|
||||
open
|
||||
update
|
||||
delay 2
|
||||
end tell
|
||||
end tell
|
||||
APPLESCRIPT
|
||||
|
||||
SetFile -a C "$MOUNT" 2>/dev/null || true
|
||||
sync
|
||||
|
||||
hdiutil detach "$DEVICE" -force >/dev/null 2>&1 \
|
||||
|| { sleep 3; hdiutil detach "$DEVICE" -force; }
|
||||
DEVICE=""
|
||||
|
||||
rm -f "$OUT_DMG"
|
||||
hdiutil convert "$RW_DMG" -format UDZO -imagekey zlib-level=9 -ov -o "$OUT_DMG"
|
||||
|
||||
echo "Created $OUT_DMG"
|
||||
+18
-30
@@ -266,26 +266,6 @@ async def _tcp_fallback(reader, writer, dst, port, relay_init, label, ctx: Crypt
|
||||
return True
|
||||
|
||||
|
||||
async def _ws_keepalive(ws, interval: float):
|
||||
"""Send periodic WS PING frames to keep the upstream flow warm.
|
||||
|
||||
A non-positive interval disables keepalive. The loop exits on send
|
||||
failure so a dead upstream is detected promptly instead of lingering
|
||||
until the next client packet (see issue #646).
|
||||
"""
|
||||
if interval <= 0:
|
||||
return
|
||||
|
||||
interval = max(1.0, interval) # reasonable minimum
|
||||
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
await ws.send_ping()
|
||||
except (asyncio.CancelledError, ConnectionError, OSError):
|
||||
return
|
||||
|
||||
|
||||
async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
|
||||
ctx: CryptoCtx,
|
||||
dc=None, is_media=False,
|
||||
@@ -302,9 +282,10 @@ async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
|
||||
up_packets = 0
|
||||
down_packets = 0
|
||||
start_time = asyncio.get_running_loop().time()
|
||||
close_reason = 'normal'
|
||||
|
||||
async def tcp_to_ws():
|
||||
nonlocal up_bytes, up_packets
|
||||
nonlocal up_bytes, up_packets, close_reason
|
||||
try:
|
||||
while True:
|
||||
chunk = await reader.read(65536)
|
||||
@@ -330,17 +311,22 @@ async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
|
||||
await ws.send(parts[0])
|
||||
else:
|
||||
await ws.send(chunk)
|
||||
except (asyncio.CancelledError, ConnectionError, OSError):
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except (ConnectionError, OSError) as e:
|
||||
close_reason = f"client: {type(e).__name__}"
|
||||
except Exception as e:
|
||||
close_reason = f"client: {type(e).__name__}: {e}"
|
||||
log.debug("[%s] tcp->ws ended: %s", label, e)
|
||||
|
||||
async def ws_to_tcp():
|
||||
nonlocal down_bytes, down_packets
|
||||
nonlocal down_bytes, down_packets, close_reason
|
||||
try:
|
||||
while True:
|
||||
data = await ws.recv()
|
||||
if data is None:
|
||||
if close_reason == 'normal':
|
||||
close_reason = 'upstream: ws_close'
|
||||
break
|
||||
n = len(data)
|
||||
stats.bytes_down += n
|
||||
@@ -350,30 +336,32 @@ async def bridge_ws_reencrypt(reader, writer, ws: RawWebSocket, label,
|
||||
data = ctx.clt_enc.update(plain)
|
||||
writer.write(data)
|
||||
await writer.drain()
|
||||
except (asyncio.CancelledError, ConnectionError, OSError):
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except (ConnectionError, OSError) as e:
|
||||
close_reason = f"upstream: {type(e).__name__}"
|
||||
except asyncio.IncompleteReadError:
|
||||
close_reason = 'upstream: tcp_reset'
|
||||
except Exception as e:
|
||||
close_reason = f"upstream: {type(e).__name__}: {e}"
|
||||
log.debug("[%s] ws->tcp ended: %s", label, e)
|
||||
|
||||
tasks = [asyncio.create_task(tcp_to_ws()),
|
||||
asyncio.create_task(ws_to_tcp())]
|
||||
keepalive = asyncio.create_task(
|
||||
_ws_keepalive(ws, proxy_config.ws_keepalive_interval))
|
||||
try:
|
||||
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
||||
finally:
|
||||
keepalive.cancel()
|
||||
for t in tasks:
|
||||
t.cancel()
|
||||
for t in (*tasks, keepalive):
|
||||
for t in tasks:
|
||||
try:
|
||||
await t
|
||||
except BaseException:
|
||||
pass
|
||||
elapsed = asyncio.get_running_loop().time() - start_time
|
||||
log.info("[%s] %s WS session closed: "
|
||||
log.info("[%s] %s WS session closed (%s): "
|
||||
"^%s (%d pkts) v%s (%d pkts) in %.1fs",
|
||||
label, dc_tag,
|
||||
label, dc_tag, close_reason,
|
||||
human_bytes(up_bytes), up_packets,
|
||||
human_bytes(down_bytes), down_packets,
|
||||
elapsed)
|
||||
|
||||
+6
-2
@@ -34,7 +34,12 @@ _CFPROXY_ENC: List[str] = [
|
||||
'khgrre.com',
|
||||
'ulihssf.com',
|
||||
'tmhqsdqmfpmk.com',
|
||||
'xwuwoqbm.com'
|
||||
'xwuwoqbm.com',
|
||||
'orgcnunpj.com',
|
||||
'zhkuldz.com',
|
||||
'zypoljnslxa.com',
|
||||
'efabnxaowuzs.com',
|
||||
'zaftuzsftqdq.com'
|
||||
]
|
||||
_S = ''.join(chr(c) for c in (46, 99, 111, 46, 117, 107))
|
||||
|
||||
@@ -67,7 +72,6 @@ class ProxyConfig:
|
||||
cfproxy_worker_domains: List[str] = field(default_factory=list)
|
||||
fake_tls_domain: str = ''
|
||||
proxy_protocol: bool = False
|
||||
ws_keepalive_interval: float = 30.0
|
||||
|
||||
|
||||
proxy_config = ProxyConfig()
|
||||
|
||||
+1
-1
@@ -111,7 +111,7 @@ class _WsPool:
|
||||
|
||||
|
||||
class _CfWorkerPool:
|
||||
WS_POOL_MAX_AGE = 120.0
|
||||
WS_POOL_MAX_AGE = 100.0
|
||||
|
||||
def __init__(self):
|
||||
self._idle: Dict[Tuple[int, str], deque] = {}
|
||||
|
||||
+25
-7
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import ssl
|
||||
import logging
|
||||
import base64
|
||||
import struct
|
||||
import asyncio
|
||||
@@ -8,6 +9,8 @@ import socket as _socket
|
||||
from typing import List, Optional, Tuple
|
||||
from .config import proxy_config
|
||||
|
||||
log = logging.getLogger('tg-mtproto-proxy')
|
||||
|
||||
|
||||
_st_BB = struct.Struct('>BB')
|
||||
_st_BBH = struct.Struct('>BBH')
|
||||
@@ -154,19 +157,15 @@ class RawWebSocket:
|
||||
self._build_frame(self.OP_BINARY, part, mask=True))
|
||||
await self.writer.drain()
|
||||
|
||||
async def send_ping(self, payload: bytes = b''):
|
||||
if self._closed:
|
||||
raise ConnectionError("WebSocket closed")
|
||||
frame = self._build_frame(self.OP_PING, payload, mask=True)
|
||||
self.writer.write(frame)
|
||||
await self.writer.drain()
|
||||
|
||||
async def recv(self) -> Optional[bytes]:
|
||||
while not self._closed:
|
||||
opcode, payload = await self._read_frame()
|
||||
|
||||
if opcode == self.OP_CLOSE:
|
||||
self._closed = True
|
||||
code, reason = self._parse_close(payload)
|
||||
log.debug("WS OP_CLOSE from upstream: code=%s reason=%r",
|
||||
code, reason)
|
||||
try:
|
||||
self.writer.write(self._build_frame(
|
||||
self.OP_CLOSE,
|
||||
@@ -209,6 +208,25 @@ class RawWebSocket:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_WS_CLOSE_REASONS = {
|
||||
1000: 'normal', 1001: 'going_away', 1002: 'protocol_error',
|
||||
1003: 'unsupported_data', 1006: 'abnormal', 1007: 'bad_data',
|
||||
1008: 'policy_violation', 1009: 'too_big', 1010: 'missing_extension',
|
||||
1011: 'internal_error',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _parse_close(cls, payload: Optional[bytes]) -> Tuple[Optional[int], str]:
|
||||
if not payload or len(payload) < 2:
|
||||
return None, ''
|
||||
try:
|
||||
code = int.from_bytes(payload[:2], 'big')
|
||||
text = payload[2:].decode('utf-8', errors='replace')
|
||||
name = cls._WS_CLOSE_REASONS.get(code)
|
||||
return code, f"{text} ({name})" if name else text
|
||||
except Exception:
|
||||
return None, ''
|
||||
|
||||
@staticmethod
|
||||
def _build_frame(opcode: int, data: bytes,
|
||||
mask: bool = False) -> bytes:
|
||||
|
||||
@@ -593,10 +593,6 @@ def main():
|
||||
ap.add_argument('--proxy-protocol', action='store_true',
|
||||
help='Accept PROXY protocol v1 header '
|
||||
'(for use behind nginx/haproxy with proxy_protocol on)')
|
||||
ap.add_argument('--ws-keepalive', type=float, default=30.0, metavar='SEC',
|
||||
help='Seconds between WebSocket keepalive PINGs to the '
|
||||
'upstream (default 30, 0 to disable). Keeps idle '
|
||||
'sessions alive through NAT/firewall timeouts.')
|
||||
args = ap.parse_args()
|
||||
|
||||
if not args.dc_ip:
|
||||
@@ -633,7 +629,6 @@ def main():
|
||||
proxy_config.cfproxy_worker_domains = coerce_domain_list(args.cfproxy_worker_domain)
|
||||
proxy_config.fake_tls_domain = args.fake_tls_domain.strip()
|
||||
proxy_config.proxy_protocol = args.proxy_protocol
|
||||
proxy_config.ws_keepalive_interval = max(0, args.ws_keepalive)
|
||||
|
||||
log_level = logging.DEBUG if args.verbose else logging.INFO
|
||||
log_fmt = logging.Formatter('%(asctime)s %(levelname)-5s %(message)s',
|
||||
|
||||
+80
-36
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Минимальная проверка новой версии через GitHub Releases API (без сторонних зависимостей).
|
||||
Проверка новой версии через GitHub Releases API
|
||||
|
||||
Ограничение частоты запросов: не чаще одного раза в час на машину (кэш в каталоге
|
||||
данных приложения). Поддерживается If-None-Match (ETag) для ответа 304.
|
||||
@@ -18,6 +18,7 @@ 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).
|
||||
@@ -223,58 +224,101 @@ def run_check(current_version: str) -> 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 []
|
||||
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")
|
||||
]
|
||||
|
||||
|
||||
def get_status() -> Dict[str, Any]:
|
||||
"""Снимок состояния после run_check (для подписей в настройках)."""
|
||||
return dict(_state)
|
||||
|
||||
|
||||
def get_update_asset(exe_path: Path) -> Optional[Tuple[str, str]]:
|
||||
assets = _state.get("assets") or []
|
||||
if not assets:
|
||||
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
|
||||
|
||||
# Try SHA256 match against release asset digests
|
||||
target_name = None
|
||||
|
||||
# SHA256 match
|
||||
try:
|
||||
import hashlib
|
||||
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 assets:
|
||||
d = (a.get("digest") or "").lower()
|
||||
if d.startswith("sha256:") and d[7:] == exe_sha:
|
||||
return a["url"], a["name"]
|
||||
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
|
||||
|
||||
# Fallback
|
||||
import platform
|
||||
import struct
|
||||
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")
|
||||
is_64 = struct.calcsize("P") * 8 == 64
|
||||
machine = platform.machine().lower()
|
||||
is_arm64 = machine in ("arm64", "aarch64")
|
||||
|
||||
try:
|
||||
is_modern = sys.getwindowsversion().major >= 10
|
||||
except Exception:
|
||||
is_modern = True
|
||||
try:
|
||||
is_modern = sys.getwindowsversion().major >= 10
|
||||
except Exception:
|
||||
is_modern = True
|
||||
|
||||
if is_arm64:
|
||||
name = "TgWsProxy_windows_arm64.exe"
|
||||
elif is_modern:
|
||||
name = "TgWsProxy_windows.exe"
|
||||
elif is_64:
|
||||
name = "TgWsProxy_windows_7_64bit.exe"
|
||||
else:
|
||||
name = "TgWsProxy_windows_7_32bit.exe"
|
||||
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 assets:
|
||||
if a.get("name") == name:
|
||||
for a in new_assets:
|
||||
if a.get("name") == target_name:
|
||||
return a["url"], a["name"]
|
||||
|
||||
return None
|
||||
|
||||
+1
-1
@@ -333,7 +333,7 @@ def _maybe_do_update(cfg: dict, is_exiting) -> None:
|
||||
return
|
||||
url = (st.get("html_url") or "").strip() or RELEASES_PAGE_URL
|
||||
ver = st.get("latest") or "?"
|
||||
asset = get_update_asset(Path(sys.executable)) if IS_FROZEN else None
|
||||
asset = get_update_asset(Path(sys.executable), __version__) if IS_FROZEN else None
|
||||
choice = update_ctk_form(
|
||||
f"Доступна новая версия: {ver}",
|
||||
download_url=asset[0] if asset else None,
|
||||
|
||||
Reference in New Issue
Block a user