From 1a12548dafbb39e8017655e3a670fc441792d361 Mon Sep 17 00:00:00 2001 From: Dark-Avery Date: Mon, 16 Mar 2026 16:36:22 +0300 Subject: [PATCH 1/8] refactor(crypto): extract AES-CTR backend adapter and keep cryptography as desktop backend --- proxy/crypto_backend.py | 37 +++++++++++++++++++++++++++++++++++++ proxy/tg_ws_proxy.py | 12 +++++------- 2 files changed, 42 insertions(+), 7 deletions(-) create mode 100644 proxy/crypto_backend.py diff --git a/proxy/crypto_backend.py b/proxy/crypto_backend.py new file mode 100644 index 0000000..d7a99a2 --- /dev/null +++ b/proxy/crypto_backend.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import os +from typing import Protocol + + +class AesCtrTransform(Protocol): + def update(self, data: bytes) -> bytes: + ... + + def finalize(self) -> bytes: + ... + + +def _create_cryptography_transform(key: bytes, + iv: bytes) -> AesCtrTransform: + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + + cipher = Cipher(algorithms.AES(key), modes.CTR(iv)) + return cipher.encryptor() + + +def create_aes_ctr_transform(key: bytes, iv: bytes, + backend: str | None = None) -> AesCtrTransform: + """ + Create a stateful AES-CTR transform. + + The backend name is configurable so Android can supply an alternative + implementation later without touching proxy logic. + """ + selected = backend or os.environ.get( + 'TG_WS_PROXY_CRYPTO_BACKEND', 'cryptography') + + if selected == 'cryptography': + return _create_cryptography_transform(key, iv) + + raise ValueError(f"Unsupported AES-CTR backend: {selected}") diff --git a/proxy/tg_ws_proxy.py b/proxy/tg_ws_proxy.py index 7912fd8..35cc3e7 100644 --- a/proxy/tg_ws_proxy.py +++ b/proxy/tg_ws_proxy.py @@ -11,7 +11,8 @@ import struct import sys import time from typing import Dict, List, Optional, Set, Tuple -from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +from proxy.crypto_backend import create_aes_ctr_transform DEFAULT_PORT = 1080 @@ -365,8 +366,7 @@ def _dc_from_init(data: bytes) -> Tuple[Optional[int], bool]: try: key = bytes(data[8:40]) iv = bytes(data[40:56]) - cipher = Cipher(algorithms.AES(key), modes.CTR(iv)) - encryptor = cipher.encryptor() + encryptor = create_aes_ctr_transform(key, iv) keystream = encryptor.update(b'\x00' * 64) + encryptor.finalize() plain = bytes(a ^ b for a, b in zip(data[56:64], keystream[56:64])) proto = struct.unpack(' bytes: try: key_raw = bytes(data[8:40]) iv = bytes(data[40:56]) - cipher = Cipher(algorithms.AES(key_raw), modes.CTR(iv)) - enc = cipher.encryptor() + enc = create_aes_ctr_transform(key_raw, iv) ks = enc.update(b'\x00' * 64) + enc.finalize() patched = bytearray(data[:64]) patched[60] = ks[60] ^ new_dc[0] @@ -424,8 +423,7 @@ class _MsgSplitter: def __init__(self, init_data: bytes): key_raw = bytes(init_data[8:40]) iv = bytes(init_data[40:56]) - cipher = Cipher(algorithms.AES(key_raw), modes.CTR(iv)) - self._dec = cipher.encryptor() + self._dec = create_aes_ctr_transform(key_raw, iv) self._dec.update(b'\x00' * 64) # skip init packet def split(self, chunk: bytes) -> List[bytes]: From 7dc9b04016d401218a591a5c5a69ff622a028019 Mon Sep 17 00:00:00 2001 From: Dark-Avery Date: Mon, 16 Mar 2026 16:43:22 +0300 Subject: [PATCH 2/8] test(crypto): add MTProto init and splitter coverage --- tests/test_crypto_mtproto.py | 85 ++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 tests/test_crypto_mtproto.py diff --git a/tests/test_crypto_mtproto.py b/tests/test_crypto_mtproto.py new file mode 100644 index 0000000..7862029 --- /dev/null +++ b/tests/test_crypto_mtproto.py @@ -0,0 +1,85 @@ +import struct +import unittest + +from proxy.crypto_backend import create_aes_ctr_transform +from proxy.tg_ws_proxy import _MsgSplitter, _dc_from_init, _patch_init_dc + + +KEY = bytes(range(32)) +IV = bytes(range(16)) +PROTO_TAG = 0xEFEFEFEF + + +def _xor(left: bytes, right: bytes) -> bytes: + return bytes(a ^ b for a, b in zip(left, right)) + + +def _keystream(size: int) -> bytes: + transform = create_aes_ctr_transform(KEY, IV) + return transform.update(b"\x00" * size) + transform.finalize() + + +def _build_init_packet(dc_raw: int, proto: int = PROTO_TAG) -> bytes: + packet = bytearray(64) + packet[8:40] = KEY + packet[40:56] = IV + + plain_tail = struct.pack(" bytes: + transform = create_aes_ctr_transform(init_packet[8:40], init_packet[40:56]) + transform.update(b"\x00" * 64) + return transform.update(plaintext) + transform.finalize() + + +class CryptoBackendTests(unittest.TestCase): + def test_unknown_backend_raises_error(self): + with self.assertRaises(ValueError): + create_aes_ctr_transform(KEY, IV, backend="missing") + + +class MtProtoInitTests(unittest.TestCase): + def test_dc_from_init_reads_non_media_dc(self): + init_packet = _build_init_packet(dc_raw=2) + + self.assertEqual(_dc_from_init(init_packet), (2, False)) + + def test_dc_from_init_reads_media_dc(self): + init_packet = _build_init_packet(dc_raw=-4) + + self.assertEqual(_dc_from_init(init_packet), (4, True)) + + def test_patch_init_dc_updates_signed_dc_and_preserves_tail(self): + original = _build_init_packet(dc_raw=99) + b"tail" + + patched = _patch_init_dc(original, -3) + + self.assertEqual(_dc_from_init(patched[:64]), (3, True)) + self.assertEqual(patched[64:], b"tail") + + +class MsgSplitterTests(unittest.TestCase): + def test_splitter_splits_multiple_abridged_messages(self): + init_packet = _build_init_packet(dc_raw=-2) + plain_chunk = b"\x01abcd\x02EFGH1234" + encrypted_chunk = _encrypt_after_init(init_packet, plain_chunk) + + parts = _MsgSplitter(init_packet).split(encrypted_chunk) + + self.assertEqual(parts, [encrypted_chunk[:5], encrypted_chunk[5:14]]) + + def test_splitter_leaves_single_message_intact(self): + init_packet = _build_init_packet(dc_raw=2) + plain_chunk = b"\x02abcdefgh" + encrypted_chunk = _encrypt_after_init(init_packet, plain_chunk) + + parts = _MsgSplitter(init_packet).split(encrypted_chunk) + + self.assertEqual(parts, [encrypted_chunk]) + + +if __name__ == "__main__": + unittest.main() From 5e6fbdffda7f550c64c83c267f1517d87df34ae0 Mon Sep 17 00:00:00 2001 From: Dark-Avery Date: Mon, 16 Mar 2026 16:46:23 +0300 Subject: [PATCH 3/8] test(socks5): cover handshake, address parsing and connect failures --- tests/test_socks5_protocol.py | 129 ++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 tests/test_socks5_protocol.py diff --git a/tests/test_socks5_protocol.py b/tests/test_socks5_protocol.py new file mode 100644 index 0000000..964cd44 --- /dev/null +++ b/tests/test_socks5_protocol.py @@ -0,0 +1,129 @@ +import asyncio +import socket +import unittest +from unittest.mock import patch + +from proxy.tg_ws_proxy import _handle_client, _socks5_reply + + +class _FakeTransport: + def get_extra_info(self, name): + return None + + def get_write_buffer_size(self): + return 0 + + +class _FakeReader: + def __init__(self, payload: bytes): + self._payload = payload + self._offset = 0 + + async def readexactly(self, n: int) -> bytes: + end = self._offset + n + if end > len(self._payload): + partial = self._payload[self._offset:] + self._offset = len(self._payload) + raise asyncio.IncompleteReadError(partial, n) + chunk = self._payload[self._offset:end] + self._offset = end + return chunk + + +class _FakeWriter: + def __init__(self): + self.transport = _FakeTransport() + self.writes = [] + self.closed = False + self.close_calls = 0 + + def get_extra_info(self, name): + if name == "peername": + return ("127.0.0.1", 50000) + return None + + def write(self, data: bytes): + self.writes.append(data) + + async def drain(self): + return None + + def close(self): + self.closed = True + self.close_calls += 1 + + async def wait_closed(self): + return None + + +def _ipv4_connect_request(ip: str, port: int, cmd: int = 1) -> bytes: + return bytes([0x05, cmd, 0x00, 0x01]) + socket.inet_aton(ip) + port.to_bytes(2, "big") + + +def _domain_connect_request(domain: str, port: int, cmd: int = 1) -> bytes: + encoded = domain.encode("utf-8") + return ( + bytes([0x05, cmd, 0x00, 0x03, len(encoded)]) + + encoded + + port.to_bytes(2, "big") + ) + + +def _ipv6_connect_request(ip: str, port: int) -> bytes: + return ( + bytes([0x05, 0x01, 0x00, 0x04]) + + socket.inet_pton(socket.AF_INET6, ip) + + port.to_bytes(2, "big") + ) + + +class Socks5ProtocolTests(unittest.IsolatedAsyncioTestCase): + async def test_rejects_non_socks5_greeting(self): + reader = _FakeReader(b"\x04\x01") + writer = _FakeWriter() + + await _handle_client(reader, writer) + + self.assertEqual(writer.writes, []) + self.assertTrue(writer.closed) + + async def test_rejects_unsupported_command(self): + reader = _FakeReader(b"\x05\x01\x00" + _ipv4_connect_request("1.1.1.1", 443, cmd=2)) + writer = _FakeWriter() + + await _handle_client(reader, writer) + + self.assertEqual(writer.writes, [b"\x05\x00", _socks5_reply(0x07)]) + self.assertTrue(writer.closed) + + async def test_rejects_unsupported_address_type(self): + reader = _FakeReader(b"\x05\x01\x00" + b"\x05\x01\x00\x02") + writer = _FakeWriter() + + await _handle_client(reader, writer) + + self.assertEqual(writer.writes, [b"\x05\x00", _socks5_reply(0x08)]) + self.assertTrue(writer.closed) + + async def test_rejects_ipv6_destinations(self): + reader = _FakeReader(b"\x05\x01\x00" + _ipv6_connect_request("2001:db8::1", 443)) + writer = _FakeWriter() + + await _handle_client(reader, writer) + + self.assertEqual(writer.writes, [b"\x05\x00", _socks5_reply(0x05)]) + self.assertTrue(writer.closed) + + async def test_passthrough_connect_failure_returns_error(self): + reader = _FakeReader(b"\x05\x01\x00" + _domain_connect_request("example.com", 443)) + writer = _FakeWriter() + + with patch("proxy.tg_ws_proxy.asyncio.open_connection", side_effect=OSError("boom")): + await _handle_client(reader, writer) + + self.assertEqual(writer.writes, [b"\x05\x00", _socks5_reply(0x05)]) + self.assertTrue(writer.closed) + + +if __name__ == "__main__": + unittest.main() From ecc89d45d6af42f1768345c7c1056980b4a331fd Mon Sep 17 00:00:00 2001 From: Dark-Avery Date: Mon, 16 Mar 2026 17:14:23 +0300 Subject: [PATCH 4/8] feat(crypto): add android-compatible pure-python AES-CTR backend --- proxy/crypto_backend.py | 175 ++++++++++++++++++++++++++++++++++- tests/test_crypto_mtproto.py | 22 +++++ 2 files changed, 195 insertions(+), 2 deletions(-) diff --git a/proxy/crypto_backend.py b/proxy/crypto_backend.py index d7a99a2..7508516 100644 --- a/proxy/crypto_backend.py +++ b/proxy/crypto_backend.py @@ -4,6 +4,33 @@ import os from typing import Protocol +_SBOX = ( + 0x63, 0x7C, 0x77, 0x7B, 0xF2, 0x6B, 0x6F, 0xC5, 0x30, 0x01, 0x67, 0x2B, + 0xFE, 0xD7, 0xAB, 0x76, 0xCA, 0x82, 0xC9, 0x7D, 0xFA, 0x59, 0x47, 0xF0, + 0xAD, 0xD4, 0xA2, 0xAF, 0x9C, 0xA4, 0x72, 0xC0, 0xB7, 0xFD, 0x93, 0x26, + 0x36, 0x3F, 0xF7, 0xCC, 0x34, 0xA5, 0xE5, 0xF1, 0x71, 0xD8, 0x31, 0x15, + 0x04, 0xC7, 0x23, 0xC3, 0x18, 0x96, 0x05, 0x9A, 0x07, 0x12, 0x80, 0xE2, + 0xEB, 0x27, 0xB2, 0x75, 0x09, 0x83, 0x2C, 0x1A, 0x1B, 0x6E, 0x5A, 0xA0, + 0x52, 0x3B, 0xD6, 0xB3, 0x29, 0xE3, 0x2F, 0x84, 0x53, 0xD1, 0x00, 0xED, + 0x20, 0xFC, 0xB1, 0x5B, 0x6A, 0xCB, 0xBE, 0x39, 0x4A, 0x4C, 0x58, 0xCF, + 0xD0, 0xEF, 0xAA, 0xFB, 0x43, 0x4D, 0x33, 0x85, 0x45, 0xF9, 0x02, 0x7F, + 0x50, 0x3C, 0x9F, 0xA8, 0x51, 0xA3, 0x40, 0x8F, 0x92, 0x9D, 0x38, 0xF5, + 0xBC, 0xB6, 0xDA, 0x21, 0x10, 0xFF, 0xF3, 0xD2, 0xCD, 0x0C, 0x13, 0xEC, + 0x5F, 0x97, 0x44, 0x17, 0xC4, 0xA7, 0x7E, 0x3D, 0x64, 0x5D, 0x19, 0x73, + 0x60, 0x81, 0x4F, 0xDC, 0x22, 0x2A, 0x90, 0x88, 0x46, 0xEE, 0xB8, 0x14, + 0xDE, 0x5E, 0x0B, 0xDB, 0xE0, 0x32, 0x3A, 0x0A, 0x49, 0x06, 0x24, 0x5C, + 0xC2, 0xD3, 0xAC, 0x62, 0x91, 0x95, 0xE4, 0x79, 0xE7, 0xC8, 0x37, 0x6D, + 0x8D, 0xD5, 0x4E, 0xA9, 0x6C, 0x56, 0xF4, 0xEA, 0x65, 0x7A, 0xAE, 0x08, + 0xBA, 0x78, 0x25, 0x2E, 0x1C, 0xA6, 0xB4, 0xC6, 0xE8, 0xDD, 0x74, 0x1F, + 0x4B, 0xBD, 0x8B, 0x8A, 0x70, 0x3E, 0xB5, 0x66, 0x48, 0x03, 0xF6, 0x0E, + 0x61, 0x35, 0x57, 0xB9, 0x86, 0xC1, 0x1D, 0x9E, 0xE1, 0xF8, 0x98, 0x11, + 0x69, 0xD9, 0x8E, 0x94, 0x9B, 0x1E, 0x87, 0xE9, 0xCE, 0x55, 0x28, 0xDF, + 0x8C, 0xA1, 0x89, 0x0D, 0xBF, 0xE6, 0x42, 0x68, 0x41, 0x99, 0x2D, 0x0F, + 0xB0, 0x54, 0xBB, 0x16, +) +_RCON = (0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1B, 0x36) + + class AesCtrTransform(Protocol): def update(self, data: bytes) -> bytes: ... @@ -12,6 +39,147 @@ class AesCtrTransform(Protocol): ... +def _xtime(value: int) -> int: + value <<= 1 + if value & 0x100: + value ^= 0x11B + return value & 0xFF + + +def _mul2(value: int) -> int: + return _xtime(value) + + +def _mul3(value: int) -> int: + return _xtime(value) ^ value + + +def _add_round_key(state: list[int], round_key: bytes): + for idx in range(16): + state[idx] ^= round_key[idx] + + +def _sub_bytes(state: list[int]): + for idx in range(16): + state[idx] = _SBOX[state[idx]] + + +def _shift_rows(state: list[int]): + state[1], state[5], state[9], state[13] = ( + state[5], state[9], state[13], state[1] + ) + state[2], state[6], state[10], state[14] = ( + state[10], state[14], state[2], state[6] + ) + state[3], state[7], state[11], state[15] = ( + state[15], state[3], state[7], state[11] + ) + + +def _mix_columns(state: list[int]): + for offset in range(0, 16, 4): + s0, s1, s2, s3 = state[offset:offset + 4] + state[offset + 0] = _mul2(s0) ^ _mul3(s1) ^ s2 ^ s3 + state[offset + 1] = s0 ^ _mul2(s1) ^ _mul3(s2) ^ s3 + state[offset + 2] = s0 ^ s1 ^ _mul2(s2) ^ _mul3(s3) + state[offset + 3] = _mul3(s0) ^ s1 ^ s2 ^ _mul2(s3) + + +def _rot_word(word: list[int]) -> list[int]: + return word[1:] + word[:1] + + +def _sub_word(word: list[int]) -> list[int]: + return [_SBOX[value] for value in word] + + +def _expand_round_keys(key: bytes) -> tuple[list[bytes], int]: + if len(key) not in (16, 24, 32): + raise ValueError("AES key must be 16, 24, or 32 bytes long") + + nk = len(key) // 4 + nr = {4: 10, 6: 12, 8: 14}[nk] + words = [list(key[idx:idx + 4]) for idx in range(0, len(key), 4)] + total_words = 4 * (nr + 1) + + for idx in range(nk, total_words): + temp = words[idx - 1][:] + if idx % nk == 0: + temp = _sub_word(_rot_word(temp)) + temp[0] ^= _RCON[idx // nk - 1] + elif nk > 6 and idx % nk == 4: + temp = _sub_word(temp) + words.append([ + words[idx - nk][byte_idx] ^ temp[byte_idx] + for byte_idx in range(4) + ]) + + round_keys = [] + for round_idx in range(nr + 1): + start = round_idx * 4 + round_keys.append(bytes(sum(words[start:start + 4], []))) + return round_keys, nr + + +class _PurePythonAesCtrTransform: + def __init__(self, key: bytes, iv: bytes): + if len(iv) != 16: + raise ValueError("AES-CTR IV must be 16 bytes long") + self._round_keys, self._rounds = _expand_round_keys(key) + self._counter = bytearray(iv) + self._buffer = b"" + self._buffer_offset = 0 + + def update(self, data: bytes) -> bytes: + if not data: + return b"" + + out = bytearray(len(data)) + data_offset = 0 + + while data_offset < len(data): + if self._buffer_offset >= len(self._buffer): + self._buffer = self._encrypt_block(bytes(self._counter)) + self._buffer_offset = 0 + self._increment_counter() + + available = len(self._buffer) - self._buffer_offset + chunk_size = min(len(data) - data_offset, available) + for chunk_idx in range(chunk_size): + out[data_offset + chunk_idx] = ( + data[data_offset + chunk_idx] + ^ self._buffer[self._buffer_offset + chunk_idx] + ) + data_offset += chunk_size + self._buffer_offset += chunk_size + + return bytes(out) + + def finalize(self) -> bytes: + return b"" + + def _encrypt_block(self, block: bytes) -> bytes: + state = list(block) + _add_round_key(state, self._round_keys[0]) + + for round_idx in range(1, self._rounds): + _sub_bytes(state) + _shift_rows(state) + _mix_columns(state) + _add_round_key(state, self._round_keys[round_idx]) + + _sub_bytes(state) + _shift_rows(state) + _add_round_key(state, self._round_keys[self._rounds]) + return bytes(state) + + def _increment_counter(self): + for idx in range(15, -1, -1): + self._counter[idx] = (self._counter[idx] + 1) & 0xFF + if self._counter[idx] != 0: + break + + def _create_cryptography_transform(key: bytes, iv: bytes) -> AesCtrTransform: from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes @@ -25,8 +193,8 @@ def create_aes_ctr_transform(key: bytes, iv: bytes, """ Create a stateful AES-CTR transform. - The backend name is configurable so Android can supply an alternative - implementation later without touching proxy logic. + Windows keeps using `cryptography` by default. Android can select the + pure-Python backend to avoid native build dependencies. """ selected = backend or os.environ.get( 'TG_WS_PROXY_CRYPTO_BACKEND', 'cryptography') @@ -34,4 +202,7 @@ def create_aes_ctr_transform(key: bytes, iv: bytes, if selected == 'cryptography': return _create_cryptography_transform(key, iv) + if selected == 'python': + return _PurePythonAesCtrTransform(key, iv) + raise ValueError(f"Unsupported AES-CTR backend: {selected}") diff --git a/tests/test_crypto_mtproto.py b/tests/test_crypto_mtproto.py index 7862029..4c5a18a 100644 --- a/tests/test_crypto_mtproto.py +++ b/tests/test_crypto_mtproto.py @@ -36,6 +36,28 @@ def _encrypt_after_init(init_packet: bytes, plaintext: bytes) -> bytes: class CryptoBackendTests(unittest.TestCase): + def test_python_backend_matches_cryptography_stream(self): + cryptography_transform = create_aes_ctr_transform( + KEY, IV, backend="cryptography") + python_transform = create_aes_ctr_transform(KEY, IV, backend="python") + + chunks = [ + b"", + b"\x00" * 16, + bytes(range(31)), + b"telegram-proxy", + b"\xff" * 64, + ] + + cryptography_out = b"".join( + cryptography_transform.update(chunk) for chunk in chunks + ) + cryptography_transform.finalize() + python_out = b"".join( + python_transform.update(chunk) for chunk in chunks + ) + python_transform.finalize() + + self.assertEqual(python_out, cryptography_out) + def test_unknown_backend_raises_error(self): with self.assertRaises(ValueError): create_aes_ctr_transform(KEY, IV, backend="missing") From ec70188385c6be5c51166c354362a9f81c7c5af4 Mon Sep 17 00:00:00 2001 From: Dark-Avery Date: Mon, 16 Mar 2026 17:22:51 +0300 Subject: [PATCH 5/8] refactor(core): extract cross-platform proxy runtime from windows app --- proxy/app_runtime.py | 170 ++++++++++++++++++++++++++++++++++++++ tests/test_app_runtime.py | 121 +++++++++++++++++++++++++++ windows.py | 134 +++++------------------------- 3 files changed, 310 insertions(+), 115 deletions(-) create mode 100644 proxy/app_runtime.py create mode 100644 tests/test_app_runtime.py diff --git a/proxy/app_runtime.py b/proxy/app_runtime.py new file mode 100644 index 0000000..3710723 --- /dev/null +++ b/proxy/app_runtime.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import asyncio as _asyncio +import json +import logging +import sys +import threading +import time +from pathlib import Path +from typing import Callable, Dict, Optional + +import proxy.tg_ws_proxy as tg_ws_proxy + + +DEFAULT_CONFIG = { + "port": 1080, + "host": "127.0.0.1", + "dc_ip": ["2:149.154.167.220", "4:149.154.167.220"], + "verbose": False, +} + + +class ProxyAppRuntime: + def __init__(self, app_dir: Path, + default_config: Optional[dict] = None, + logger_name: str = "tg-ws-runtime", + on_error: Optional[Callable[[str], None]] = None, + parse_dc_ip_list: Optional[ + Callable[[list[str]], Dict[int, str]] + ] = None, + run_proxy: Optional[Callable[..., object]] = None, + thread_factory: Optional[Callable[..., object]] = None): + self.app_dir = Path(app_dir) + self.config_file = self.app_dir / "config.json" + self.log_file = self.app_dir / "proxy.log" + self.default_config = dict(default_config or DEFAULT_CONFIG) + self.log = logging.getLogger(logger_name) + self.on_error = on_error + self.parse_dc_ip_list = parse_dc_ip_list or tg_ws_proxy.parse_dc_ip_list + self.run_proxy = run_proxy or tg_ws_proxy._run + self.thread_factory = thread_factory or threading.Thread + self.config: dict = {} + self._proxy_thread = None + self._async_stop = None + + def ensure_dirs(self): + self.app_dir.mkdir(parents=True, exist_ok=True) + + def load_config(self) -> dict: + self.ensure_dirs() + if self.config_file.exists(): + try: + with open(self.config_file, "r", encoding="utf-8") as f: + data = json.load(f) + for key, value in self.default_config.items(): + data.setdefault(key, value) + self.config = data + return data + except Exception as exc: + self.log.warning("Failed to load config: %s", exc) + + self.config = dict(self.default_config) + return dict(self.config) + + def save_config(self, cfg: dict): + self.ensure_dirs() + self.config = dict(cfg) + with open(self.config_file, "w", encoding="utf-8") as f: + json.dump(cfg, f, indent=2, ensure_ascii=False) + + def reset_log_file(self): + if self.log_file.exists(): + try: + self.log_file.unlink() + except Exception: + pass + + def setup_logging(self, verbose: bool = False): + self.ensure_dirs() + root = logging.getLogger() + root.setLevel(logging.DEBUG if verbose else logging.INFO) + + fh = logging.FileHandler(str(self.log_file), encoding="utf-8") + fh.setLevel(logging.DEBUG) + fh.setFormatter(logging.Formatter( + "%(asctime)s %(levelname)-5s %(name)s %(message)s", + datefmt="%Y-%m-%d %H:%M:%S")) + root.addHandler(fh) + + if not getattr(sys, "frozen", False): + ch = logging.StreamHandler(sys.stdout) + ch.setLevel(logging.DEBUG if verbose else logging.INFO) + ch.setFormatter(logging.Formatter( + "%(asctime)s %(levelname)-5s %(message)s", + datefmt="%H:%M:%S")) + root.addHandler(ch) + + def prepare(self) -> dict: + cfg = self.load_config() + self.save_config(cfg) + return cfg + + def _emit_error(self, text: str): + if self.on_error: + self.on_error(text) + + def _run_proxy_thread(self, port: int, dc_opt: Dict[int, str], + host: str = "127.0.0.1"): + loop = _asyncio.new_event_loop() + _asyncio.set_event_loop(loop) + stop_ev = _asyncio.Event() + self._async_stop = (loop, stop_ev) + + try: + loop.run_until_complete( + self.run_proxy(port, dc_opt, stop_event=stop_ev, host=host)) + except Exception as exc: + self.log.error("Proxy thread crashed: %s", exc) + if ("10048" in str(exc) or + "Address already in use" in str(exc)): + self._emit_error( + "Не удалось запустить прокси:\n" + "Порт уже используется другим приложением.\n\n" + "Закройте приложение, использующее этот порт, " + "или измените порт в настройках прокси и перезапустите.") + finally: + loop.close() + self._async_stop = None + + def start_proxy(self, cfg: Optional[dict] = None) -> bool: + if self._proxy_thread and self._proxy_thread.is_alive(): + self.log.info("Proxy already running") + return True + + active_cfg = dict(cfg or self.config or self.default_config) + self.config = dict(active_cfg) + port = active_cfg.get("port", self.default_config["port"]) + host = active_cfg.get("host", self.default_config["host"]) + dc_ip_list = active_cfg.get("dc_ip", self.default_config["dc_ip"]) + + try: + dc_opt = self.parse_dc_ip_list(dc_ip_list) + except ValueError as exc: + self.log.error("Bad config dc_ip: %s", exc) + self._emit_error("Ошибка конфигурации:\n%s" % exc) + return False + + self.log.info("Starting proxy on %s:%d ...", host, port) + self._proxy_thread = self.thread_factory( + target=self._run_proxy_thread, + args=(port, dc_opt, host), + daemon=True, + name="proxy") + self._proxy_thread.start() + return True + + def stop_proxy(self): + if self._async_stop: + loop, stop_ev = self._async_stop + loop.call_soon_threadsafe(stop_ev.set) + if self._proxy_thread: + self._proxy_thread.join(timeout=2) + self._proxy_thread = None + self.log.info("Proxy stopped") + + def restart_proxy(self, delay_seconds: float = 0.3) -> bool: + self.log.info("Restarting proxy...") + self.stop_proxy() + time.sleep(delay_seconds) + return self.start_proxy() diff --git a/tests/test_app_runtime.py b/tests/test_app_runtime.py new file mode 100644 index 0000000..b6026f9 --- /dev/null +++ b/tests/test_app_runtime.py @@ -0,0 +1,121 @@ +import json +import tempfile +import unittest +from pathlib import Path + +from proxy.app_runtime import DEFAULT_CONFIG, ProxyAppRuntime + + +class _FakeThread: + def __init__(self, target=None, args=(), daemon=None, name=None): + self.target = target + self.args = args + self.daemon = daemon + self.name = name + self.started = False + self.join_timeout = None + self._alive = False + + def start(self): + self.started = True + self._alive = True + + def is_alive(self): + return self._alive + + def join(self, timeout=None): + self.join_timeout = timeout + self._alive = False + + +class ProxyAppRuntimeTests(unittest.TestCase): + def test_load_config_returns_defaults_when_missing(self): + with tempfile.TemporaryDirectory() as tmpdir: + runtime = ProxyAppRuntime(Path(tmpdir)) + + cfg = runtime.load_config() + + self.assertEqual(cfg, DEFAULT_CONFIG) + + def test_load_config_merges_defaults_into_saved_config(self): + with tempfile.TemporaryDirectory() as tmpdir: + app_dir = Path(tmpdir) + config_path = app_dir / "config.json" + app_dir.mkdir(parents=True, exist_ok=True) + config_path.write_text( + json.dumps({"port": 9050, "host": "127.0.0.2"}), + encoding="utf-8") + runtime = ProxyAppRuntime(app_dir) + + cfg = runtime.load_config() + + self.assertEqual(cfg["port"], 9050) + self.assertEqual(cfg["host"], "127.0.0.2") + self.assertEqual(cfg["dc_ip"], DEFAULT_CONFIG["dc_ip"]) + self.assertEqual(cfg["verbose"], DEFAULT_CONFIG["verbose"]) + + def test_invalid_config_file_falls_back_to_defaults(self): + with tempfile.TemporaryDirectory() as tmpdir: + app_dir = Path(tmpdir) + app_dir.mkdir(parents=True, exist_ok=True) + (app_dir / "config.json").write_text("{broken", encoding="utf-8") + runtime = ProxyAppRuntime(app_dir) + + cfg = runtime.load_config() + + self.assertEqual(cfg, DEFAULT_CONFIG) + + def test_start_proxy_starts_thread_with_parsed_dc_options(self): + with tempfile.TemporaryDirectory() as tmpdir: + captured = {} + thread_holder = {} + + def fake_parse(entries): + captured["dc_ip"] = list(entries) + return {2: "149.154.167.220"} + + def fake_thread_factory(**kwargs): + thread = _FakeThread(**kwargs) + thread_holder["thread"] = thread + return thread + + runtime = ProxyAppRuntime( + Path(tmpdir), + parse_dc_ip_list=fake_parse, + thread_factory=fake_thread_factory) + + started = runtime.start_proxy(dict(DEFAULT_CONFIG)) + + self.assertTrue(started) + self.assertEqual(captured["dc_ip"], DEFAULT_CONFIG["dc_ip"]) + self.assertTrue(thread_holder["thread"].started) + self.assertEqual( + thread_holder["thread"].args, + (DEFAULT_CONFIG["port"], {2: "149.154.167.220"}, + DEFAULT_CONFIG["host"])) + + def test_start_proxy_reports_bad_config(self): + with tempfile.TemporaryDirectory() as tmpdir: + errors = [] + + def fake_parse(entries): + raise ValueError("bad dc mapping") + + runtime = ProxyAppRuntime( + Path(tmpdir), + parse_dc_ip_list=fake_parse, + on_error=errors.append) + + started = runtime.start_proxy({ + "host": "127.0.0.1", + "port": 1080, + "dc_ip": ["broken"], + "verbose": False, + }) + + self.assertFalse(started) + self.assertEqual(errors, ["Ошибка конфигурации:\nbad dc mapping"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/windows.py b/windows.py index 64e581b..c96531d 100644 --- a/windows.py +++ b/windows.py @@ -11,39 +11,33 @@ import time import webbrowser import pystray import pyperclip -import asyncio as _asyncio import customtkinter as ctk from pathlib import Path -from typing import Dict, Optional +from typing import Optional from PIL import Image, ImageDraw, ImageFont import proxy.tg_ws_proxy as tg_ws_proxy +from proxy.app_runtime import DEFAULT_CONFIG, ProxyAppRuntime APP_NAME = "TgWsProxy" APP_DIR = Path(os.environ.get("APPDATA", Path.home())) / APP_NAME -CONFIG_FILE = APP_DIR / "config.json" -LOG_FILE = APP_DIR / "proxy.log" FIRST_RUN_MARKER = APP_DIR / ".first_run_done" IPV6_WARN_MARKER = APP_DIR / ".ipv6_warned" - - -DEFAULT_CONFIG = { - "port": 1080, - "host": "127.0.0.1", - "dc_ip": ["2:149.154.167.220", "4:149.154.167.220"], - "verbose": False, -} - - -_proxy_thread: Optional[threading.Thread] = None -_async_stop: Optional[object] = None _tray_icon: Optional[object] = None _config: dict = {} _exiting: bool = False _lock_file_path: Optional[Path] = None log = logging.getLogger("tg-ws-tray") +_runtime = ProxyAppRuntime( + APP_DIR, + default_config=DEFAULT_CONFIG, + logger_name="tg-ws-tray", + on_error=lambda text: _show_error(text), +) +CONFIG_FILE = _runtime.config_file +LOG_FILE = _runtime.log_file def _same_process(lock_meta: dict, proc: psutil.Process) -> bool: @@ -120,48 +114,19 @@ def _acquire_lock() -> bool: def _ensure_dirs(): - APP_DIR.mkdir(parents=True, exist_ok=True) + _runtime.ensure_dirs() def load_config() -> dict: - _ensure_dirs() - if CONFIG_FILE.exists(): - try: - with open(CONFIG_FILE, "r", encoding="utf-8") as f: - data = json.load(f) - for k, v in DEFAULT_CONFIG.items(): - data.setdefault(k, v) - return data - except Exception as exc: - log.warning("Failed to load config: %s", exc) - return dict(DEFAULT_CONFIG) + return _runtime.load_config() def save_config(cfg: dict): - _ensure_dirs() - with open(CONFIG_FILE, "w", encoding="utf-8") as f: - json.dump(cfg, f, indent=2, ensure_ascii=False) + _runtime.save_config(cfg) def setup_logging(verbose: bool = False): - _ensure_dirs() - root = logging.getLogger() - root.setLevel(logging.DEBUG if verbose else logging.INFO) - - fh = logging.FileHandler(str(LOG_FILE), encoding="utf-8") - fh.setLevel(logging.DEBUG) - fh.setFormatter(logging.Formatter( - "%(asctime)s %(levelname)-5s %(name)s %(message)s", - datefmt="%Y-%m-%d %H:%M:%S")) - root.addHandler(fh) - - if not getattr(sys, "frozen", False): - ch = logging.StreamHandler(sys.stdout) - ch.setLevel(logging.DEBUG if verbose else logging.INFO) - ch.setFormatter(logging.Formatter( - "%(asctime)s %(levelname)-5s %(message)s", - datefmt="%H:%M:%S")) - root.addHandler(ch) + _runtime.setup_logging(verbose) def _make_icon_image(size: int = 64): @@ -196,71 +161,16 @@ def _load_icon(): pass return _make_icon_image() - - -def _run_proxy_thread(port: int, dc_opt: Dict[int, str], verbose: bool, - host: str = '127.0.0.1'): - global _async_stop - loop = _asyncio.new_event_loop() - _asyncio.set_event_loop(loop) - stop_ev = _asyncio.Event() - _async_stop = (loop, stop_ev) - - try: - loop.run_until_complete( - tg_ws_proxy._run(port, dc_opt, stop_event=stop_ev, host=host)) - except Exception as exc: - log.error("Proxy thread crashed: %s", exc) - if "10048" in str(exc) or "Address already in use" in str(exc): - _show_error("Не удалось запустить прокси:\nПорт уже используется другим приложением.\n\nЗакройте приложение, использующее этот порт, или измените порт в настройках прокси и перезапустите.") - finally: - loop.close() - _async_stop = None - - def start_proxy(): - global _proxy_thread, _config - if _proxy_thread and _proxy_thread.is_alive(): - log.info("Proxy already running") - return - - cfg = _config - port = cfg.get("port", DEFAULT_CONFIG["port"]) - host = cfg.get("host", DEFAULT_CONFIG["host"]) - dc_ip_list = cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"]) - verbose = cfg.get("verbose", False) - - try: - dc_opt = tg_ws_proxy.parse_dc_ip_list(dc_ip_list) - except ValueError as e: - log.error("Bad config dc_ip: %s", e) - _show_error(f"Ошибка конфигурации:\n{e}") - return - - log.info("Starting proxy on %s:%d ...", host, port) - _proxy_thread = threading.Thread( - target=_run_proxy_thread, - args=(port, dc_opt, verbose, host), - daemon=True, name="proxy") - _proxy_thread.start() + _runtime.start_proxy(_config) def stop_proxy(): - global _proxy_thread, _async_stop - if _async_stop: - loop, stop_ev = _async_stop - loop.call_soon_threadsafe(stop_ev.set) - if _proxy_thread: - _proxy_thread.join(timeout=2) - _proxy_thread = None - log.info("Proxy stopped") + _runtime.stop_proxy() def restart_proxy(): - log.info("Restarting proxy...") - stop_proxy() - time.sleep(0.3) - start_proxy() + _runtime.restart_proxy() def _show_error(text: str, title: str = "TG WS Proxy — Ошибка"): @@ -642,14 +552,8 @@ def _build_menu(): def run_tray(): global _tray_icon, _config - _config = load_config() - save_config(_config) - - if LOG_FILE.exists(): - try: - LOG_FILE.unlink() - except Exception: - pass + _config = _runtime.prepare() + _runtime.reset_log_file() setup_logging(_config.get("verbose", False)) log.info("TG WS Proxy tray app starting") From 47e5c6241d7d389b80915541997e70ca22323e3a Mon Sep 17 00:00:00 2001 From: Dark-Avery Date: Mon, 16 Mar 2026 19:45:57 +0300 Subject: [PATCH 6/8] feat(android): scaffold kotlin app with settings screen and foreground service shell --- .gitignore | 7 + android/app/build.gradle.kts | 55 ++++ android/app/proguard-rules.pro | 1 + android/app/src/main/AndroidManifest.xml | 34 +++ .../org/flowseal/tgwsproxy/MainActivity.kt | 132 ++++++++++ .../org/flowseal/tgwsproxy/ProxyConfig.kt | 84 ++++++ .../tgwsproxy/ProxyForegroundService.kt | 98 +++++++ .../flowseal/tgwsproxy/ProxyServiceState.kt | 22 ++ .../flowseal/tgwsproxy/ProxySettingsStore.kt | 36 +++ .../src/main/res/drawable/ic_proxy_app.xml | 13 + .../app/src/main/res/layout/activity_main.xml | 151 +++++++++++ android/app/src/main/res/values/colors.xml | 7 + android/app/src/main/res/values/strings.xml | 22 ++ android/app/src/main/res/values/themes.xml | 11 + android/build-local-debug.sh | 61 +++++ android/build.gradle.kts | 4 + android/gradle.properties | 6 + android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43453 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + android/gradlew | 249 ++++++++++++++++++ android/gradlew.bat | 92 +++++++ android/settings.gradle.kts | 18 ++ 22 files changed, 1110 insertions(+) create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/proguard-rules.pro create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/java/org/flowseal/tgwsproxy/MainActivity.kt create mode 100644 android/app/src/main/java/org/flowseal/tgwsproxy/ProxyConfig.kt create mode 100644 android/app/src/main/java/org/flowseal/tgwsproxy/ProxyForegroundService.kt create mode 100644 android/app/src/main/java/org/flowseal/tgwsproxy/ProxyServiceState.kt create mode 100644 android/app/src/main/java/org/flowseal/tgwsproxy/ProxySettingsStore.kt create mode 100644 android/app/src/main/res/drawable/ic_proxy_app.xml create mode 100644 android/app/src/main/res/layout/activity_main.xml create mode 100644 android/app/src/main/res/values/colors.xml create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/themes.xml create mode 100644 android/build-local-debug.sh create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100644 android/gradlew create mode 100644 android/gradlew.bat create mode 100644 android/settings.gradle.kts diff --git a/.gitignore b/.gitignore index 42a354f..139f38e 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,13 @@ build/ .idea/ *.swp *.swo +.gradle/ +.gradle-local/ +android/.gradle-local/ +local.properties +android/.idea/ +android/build/ +android/app/build/ # OS Thumbs.db diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..3929a27 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,55 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "org.flowseal.tgwsproxy" + compileSdk = 34 + + defaultConfig { + applicationId = "org.flowseal.tgwsproxy" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "0.1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + viewBinding = true + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.appcompat:appcompat:1.7.0") + implementation("androidx.activity:activity-ktx:1.9.2") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.6") + implementation("androidx.lifecycle:lifecycle-service:2.8.6") + implementation("com.google.android.material:material:1.12.0") + + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..dc67027 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1 @@ +# Intentionally empty for the initial Android shell. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..132b7ef --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/org/flowseal/tgwsproxy/MainActivity.kt b/android/app/src/main/java/org/flowseal/tgwsproxy/MainActivity.kt new file mode 100644 index 0000000..b017348 --- /dev/null +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/MainActivity.kt @@ -0,0 +1,132 @@ +package org.flowseal.tgwsproxy + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.launch +import org.flowseal.tgwsproxy.databinding.ActivityMainBinding + +class MainActivity : AppCompatActivity() { + private lateinit var binding: ActivityMainBinding + private lateinit var settingsStore: ProxySettingsStore + + private val notificationPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + if (!granted) { + Toast.makeText( + this, + "Без уведомлений Android может скрыть foreground service.", + Toast.LENGTH_LONG, + ).show() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityMainBinding.inflate(layoutInflater) + settingsStore = ProxySettingsStore(this) + setContentView(binding.root) + + binding.startButton.setOnClickListener { onStartClicked() } + binding.stopButton.setOnClickListener { ProxyForegroundService.stop(this) } + binding.saveButton.setOnClickListener { onSaveClicked(showMessage = true) } + + renderConfig(settingsStore.load()) + requestNotificationPermissionIfNeeded() + observeServiceState() + } + + private fun onSaveClicked(showMessage: Boolean): NormalizedProxyConfig? { + val validation = collectConfigFromForm().validate() + val config = validation.normalized + if (config == null) { + binding.errorText.text = validation.errorMessage + binding.errorText.isVisible = true + return null + } + + binding.errorText.isVisible = false + settingsStore.save(config) + if (showMessage) { + Snackbar.make(binding.root, R.string.settings_saved, Snackbar.LENGTH_SHORT).show() + } + return config + } + + private fun onStartClicked() { + onSaveClicked(showMessage = false) ?: return + ProxyForegroundService.start(this) + Snackbar.make(binding.root, R.string.service_start_requested, Snackbar.LENGTH_SHORT).show() + } + + private fun renderConfig(config: ProxyConfig) { + binding.hostInput.setText(config.host) + binding.portInput.setText(config.portText) + binding.dcIpInput.setText(config.dcIpText) + binding.verboseSwitch.isChecked = config.verbose + } + + private fun collectConfigFromForm(): ProxyConfig { + return ProxyConfig( + host = binding.hostInput.text?.toString().orEmpty(), + portText = binding.portInput.text?.toString().orEmpty(), + dcIpText = binding.dcIpInput.text?.toString().orEmpty(), + verbose = binding.verboseSwitch.isChecked, + ) + } + + private fun observeServiceState() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + ProxyServiceState.isRunning.collect { isRunning -> + binding.statusValue.text = getString( + if (isRunning) R.string.status_running else R.string.status_stopped, + ) + binding.startButton.isEnabled = !isRunning + binding.stopButton.isEnabled = isRunning + } + } + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + ProxyServiceState.activeConfig.collect { config -> + binding.serviceHint.text = if (config == null) { + getString(R.string.service_hint_idle) + } else { + getString( + R.string.service_hint_running, + config.host, + config.port, + ) + } + } + } + } + } + + private fun requestNotificationPermissionIfNeeded() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + return + } + if (ContextCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + ) { + return + } + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } +} diff --git a/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyConfig.kt b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyConfig.kt new file mode 100644 index 0000000..a8ffcde --- /dev/null +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyConfig.kt @@ -0,0 +1,84 @@ +package org.flowseal.tgwsproxy + +data class ProxyConfig( + val host: String = DEFAULT_HOST, + val portText: String = DEFAULT_PORT.toString(), + val dcIpText: String = DEFAULT_DC_IP_LINES.joinToString("\n"), + val verbose: Boolean = false, +) { + fun validate(): ValidationResult { + val hostValue = host.trim() + if (!isIpv4Address(hostValue)) { + return ValidationResult(errorMessage = "IP-адрес прокси указан некорректно.") + } + + val portValue = portText.trim().toIntOrNull() + ?: return ValidationResult(errorMessage = "Порт должен быть числом.") + if (portValue !in 1..65535) { + return ValidationResult(errorMessage = "Порт должен быть в диапазоне 1-65535.") + } + + val lines = dcIpText + .lineSequence() + .map { it.trim() } + .filter { it.isNotEmpty() } + .toList() + + if (lines.isEmpty()) { + return ValidationResult(errorMessage = "Добавьте хотя бы один DC:IP маппинг.") + } + + for (line in lines) { + val parts = line.split(":", limit = 2) + val dcValue = parts.firstOrNull()?.toIntOrNull() + val ipValue = parts.getOrNull(1)?.trim().orEmpty() + if (parts.size != 2 || dcValue == null || !isIpv4Address(ipValue)) { + return ValidationResult(errorMessage = "Строка \"$line\" должна быть в формате DC:IP.") + } + } + + return ValidationResult( + normalized = NormalizedProxyConfig( + host = hostValue, + port = portValue, + dcIpList = lines, + verbose = verbose, + ) + ) + } + + companion object { + const val DEFAULT_HOST = "127.0.0.1" + const val DEFAULT_PORT = 1080 + val DEFAULT_DC_IP_LINES = listOf( + "2:149.154.167.220", + "4:149.154.167.220", + ) + + private fun isIpv4Address(value: String): Boolean { + val octets = value.split(".") + if (octets.size != 4) { + return false + } + + return octets.all { octet -> + octet.isNotEmpty() && + octet.length <= 3 && + octet.all(Char::isDigit) && + octet.toIntOrNull() in 0..255 + } + } + } +} + +data class ValidationResult( + val normalized: NormalizedProxyConfig? = null, + val errorMessage: String? = null, +) + +data class NormalizedProxyConfig( + val host: String, + val port: Int, + val dcIpList: List, + val verbose: Boolean, +) diff --git a/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyForegroundService.kt b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyForegroundService.kt new file mode 100644 index 0000000..fa71c27 --- /dev/null +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyForegroundService.kt @@ -0,0 +1,98 @@ +package org.flowseal.tgwsproxy + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat + +class ProxyForegroundService : Service() { + private lateinit var settingsStore: ProxySettingsStore + + override fun onCreate() { + super.onCreate() + settingsStore = ProxySettingsStore(this) + createNotificationChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + return when (intent?.action) { + ACTION_STOP -> { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + START_NOT_STICKY + } + + else -> { + val config = settingsStore.load().validate().normalized + if (config == null) { + stopSelf() + START_NOT_STICKY + } else { + ProxyServiceState.markStarted(config) + startForeground(NOTIFICATION_ID, buildNotification(config)) + START_STICKY + } + } + } + } + + override fun onDestroy() { + ProxyServiceState.markStopped() + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun buildNotification(config: NormalizedProxyConfig): Notification { + val contentText = "SOCKS5 ${config.host}:${config.port} • service shell active" + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.notification_title)) + .setContentText(contentText) + .setSmallIcon(android.R.drawable.stat_sys_download_done) + .setOngoing(true) + .setOnlyAlertOnce(true) + .build() + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return + } + + val manager = getSystemService(NotificationManager::class.java) + val channel = NotificationChannel( + CHANNEL_ID, + getString(R.string.notification_channel_name), + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = getString(R.string.notification_channel_description) + } + manager.createNotificationChannel(channel) + } + + companion object { + private const val CHANNEL_ID = "proxy_service" + private const val NOTIFICATION_ID = 1001 + private const val ACTION_START = "org.flowseal.tgwsproxy.action.START" + private const val ACTION_STOP = "org.flowseal.tgwsproxy.action.STOP" + + fun start(context: Context) { + val intent = Intent(context, ProxyForegroundService::class.java).apply { + action = ACTION_START + } + androidx.core.content.ContextCompat.startForegroundService(context, intent) + } + + fun stop(context: Context) { + val intent = Intent(context, ProxyForegroundService::class.java).apply { + action = ACTION_STOP + } + context.startService(intent) + } + } +} diff --git a/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyServiceState.kt b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyServiceState.kt new file mode 100644 index 0000000..1488c9c --- /dev/null +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyServiceState.kt @@ -0,0 +1,22 @@ +package org.flowseal.tgwsproxy + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +object ProxyServiceState { + private val _isRunning = MutableStateFlow(false) + val isRunning: StateFlow = _isRunning + + private val _activeConfig = MutableStateFlow(null) + val activeConfig: StateFlow = _activeConfig + + fun markStarted(config: NormalizedProxyConfig) { + _activeConfig.value = config + _isRunning.value = true + } + + fun markStopped() { + _activeConfig.value = null + _isRunning.value = false + } +} diff --git a/android/app/src/main/java/org/flowseal/tgwsproxy/ProxySettingsStore.kt b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxySettingsStore.kt new file mode 100644 index 0000000..e93249d --- /dev/null +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxySettingsStore.kt @@ -0,0 +1,36 @@ +package org.flowseal.tgwsproxy + +import android.content.Context + +class ProxySettingsStore(context: Context) { + private val preferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + fun load(): ProxyConfig { + return ProxyConfig( + host = preferences.getString(KEY_HOST, ProxyConfig.DEFAULT_HOST).orEmpty(), + portText = preferences.getInt(KEY_PORT, ProxyConfig.DEFAULT_PORT).toString(), + dcIpText = preferences.getString( + KEY_DC_IP_TEXT, + ProxyConfig.DEFAULT_DC_IP_LINES.joinToString("\n"), + ).orEmpty(), + verbose = preferences.getBoolean(KEY_VERBOSE, false), + ) + } + + fun save(config: NormalizedProxyConfig) { + preferences.edit() + .putString(KEY_HOST, config.host) + .putInt(KEY_PORT, config.port) + .putString(KEY_DC_IP_TEXT, config.dcIpList.joinToString("\n")) + .putBoolean(KEY_VERBOSE, config.verbose) + .apply() + } + + companion object { + private const val PREFS_NAME = "proxy_settings" + private const val KEY_HOST = "host" + private const val KEY_PORT = "port" + private const val KEY_DC_IP_TEXT = "dc_ip_text" + private const val KEY_VERBOSE = "verbose" + } +} diff --git a/android/app/src/main/res/drawable/ic_proxy_app.xml b/android/app/src/main/res/drawable/ic_proxy_app.xml new file mode 100644 index 0000000..3608bba --- /dev/null +++ b/android/app/src/main/res/drawable/ic_proxy_app.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..04e35a2 --- /dev/null +++ b/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..4317b48 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,7 @@ + + + #1E88E5 + #0B1F33 + #F4F8FC + #FFFFFF + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..d124aa2 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,22 @@ + + + TG WS Proxy + Android shell for the local Telegram SOCKS5 proxy. The embedded Python runtime will be wired in the next commit. + Foreground service + Running + Stopped + The Android service shell is ready. Save settings, then start the service. + Foreground service active for %1$s:%2$d. Python proxy bootstrap will be connected next. + Proxy host + Proxy port + DC to IP mappings (one DC:IP per line) + Verbose logging + Save Settings + Start Service + Stop Service + Settings saved + Foreground service start requested + TG WS Proxy + Proxy service + Keeps the Telegram proxy service alive in the foreground. + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..48198c7 --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,11 @@ + + + + diff --git a/android/build-local-debug.sh b/android/build-local-debug.sh new file mode 100644 index 0000000..139e58c --- /dev/null +++ b/android/build-local-debug.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [[ -z "${GRADLE_USER_HOME:-}" ]]; then + if [[ -d "$HOME/.gradle" && -w "$HOME/.gradle" ]]; then + export GRADLE_USER_HOME="$HOME/.gradle" + else + export GRADLE_USER_HOME="$ROOT_DIR/.gradle-local" + fi +fi + +mkdir -p "$GRADLE_USER_HOME" + +if [[ -d "$HOME/.local/jdk" ]]; then + export JAVA_HOME="$HOME/.local/jdk" +fi + +if [[ -d "$HOME/android-sdk" ]]; then + export ANDROID_SDK_ROOT="$HOME/android-sdk" +fi + +if [[ -n "${JAVA_HOME:-}" ]]; then + export PATH="$JAVA_HOME/bin:$PATH" +fi + +if [[ -n "${ANDROID_SDK_ROOT:-}" ]]; then + export PATH="$ANDROID_SDK_ROOT/cmdline-tools/latest/bin:$ANDROID_SDK_ROOT/platform-tools:$PATH" +fi + +if [[ -d "$HOME/.local/gradle/gradle-8.7/bin" ]]; then + export PATH="$HOME/.local/gradle/gradle-8.7/bin:$PATH" +fi + +unset HTTP_PROXY HTTPS_PROXY ALL_PROXY http_proxy https_proxy all_proxy + +GRADLE_BIN="gradle" +if [[ -x "$ROOT_DIR/gradlew" ]]; then + GRADLE_BIN="$ROOT_DIR/gradlew" +fi + +ATTEMPTS="${ATTEMPTS:-5}" +SLEEP_SECONDS="${SLEEP_SECONDS:-15}" +TASK="${1:-assembleDebug}" + +for attempt in $(seq 1 "$ATTEMPTS"); do + echo "==> Android build attempt $attempt/$ATTEMPTS ($TASK)" + if "$GRADLE_BIN" --no-daemon --console=plain "$TASK"; then + exit 0 + fi + + if [[ "$attempt" -lt "$ATTEMPTS" ]]; then + echo "Build failed, retrying in ${SLEEP_SECONDS}s..." + sleep "$SLEEP_SECONDS" + fi +done + +echo "Android build failed after $ATTEMPTS attempts." +exit 1 diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..017d909 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.application") version "8.5.2" apply false + id("org.jetbrains.kotlin.android") version "1.9.24" apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..84e8962 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official +systemProp.org.gradle.internal.http.connectionTimeout=120000 +systemProp.org.gradle.internal.http.socketTimeout=120000 diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..e6441136f3d4ba8a0da8d277868979cfbc8ad796 GIT binary patch literal 43453 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 yu?e%!_ptzGB|8GrCfu%p?(_ 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
NnX)EH+Nua)3Y(c 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..25da30d --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..94a30ff --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "tg-ws-proxy-android" +include(":app") From ec6de3afb32b4f1fbd7ca84df41569b4dc926443 Mon Sep 17 00:00:00 2001 From: Dark-Avery Date: Mon, 16 Mar 2026 21:13:35 +0300 Subject: [PATCH 7/8] feat(android): embed python runtime and boot proxy service inside foreground service --- .gitignore | 1 + android/app/build.gradle.kts | 27 ++++++ .../org/flowseal/tgwsproxy/MainActivity.kt | 47 +++++++++- .../tgwsproxy/ProxyForegroundService.kt | 65 +++++++++++-- .../flowseal/tgwsproxy/ProxyServiceState.kt | 27 ++++++ .../flowseal/tgwsproxy/PythonProxyBridge.kt | 39 ++++++++ .../src/main/python/android_proxy_bridge.py | 92 +++++++++++++++++++ android/app/src/main/res/values/strings.xml | 12 ++- android/build-local-debug.sh | 45 +++++++++ android/build.gradle.kts | 1 + android/settings.gradle.kts | 10 ++ proxy/__init__.py | 1 + proxy/app_runtime.py | 13 +++ 13 files changed, 366 insertions(+), 14 deletions(-) create mode 100644 android/app/src/main/java/org/flowseal/tgwsproxy/PythonProxyBridge.kt create mode 100644 android/app/src/main/python/android_proxy_bridge.py create mode 100644 proxy/__init__.py diff --git a/.gitignore b/.gitignore index 139f38e..28c6140 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ build/ .gradle/ .gradle-local/ android/.gradle-local/ +android/.m2-chaquopy*/ local.properties android/.idea/ android/build/ diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 3929a27..18b3f4a 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,8 +1,19 @@ +import org.gradle.api.tasks.Sync + plugins { id("com.android.application") + id("com.chaquo.python") id("org.jetbrains.kotlin.android") } +val stagedPythonSourcesDir = layout.buildDirectory.dir("generated/chaquopy/python") +val stagePythonSources by tasks.registering(Sync::class) { + from(rootProject.projectDir.resolve("../proxy")) { + into("proxy") + } + into(stagedPythonSourcesDir) +} + android { namespace = "org.flowseal.tgwsproxy" compileSdk = 34 @@ -15,6 +26,10 @@ android { versionName = "0.1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + ndk { + abiFilters += listOf("arm64-v8a", "x86_64") + } } buildTypes { @@ -41,6 +56,18 @@ android { } } +chaquopy { + defaultConfig { + version = "3.12" + } + sourceSets { + getByName("main") { + srcDir("src/main/python") + srcDir(stagePythonSources) + } + } +} + dependencies { implementation("androidx.core:core-ktx:1.13.1") implementation("androidx.appcompat:appcompat:1.7.0") diff --git a/android/app/src/main/java/org/flowseal/tgwsproxy/MainActivity.kt b/android/app/src/main/java/org/flowseal/tgwsproxy/MainActivity.kt index b017348..fe311c0 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/MainActivity.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/MainActivity.kt @@ -13,6 +13,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import org.flowseal.tgwsproxy.databinding.ActivityMainBinding @@ -89,21 +90,41 @@ class MainActivity : AppCompatActivity() { private fun observeServiceState() { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { - ProxyServiceState.isRunning.collect { isRunning -> + combine( + ProxyServiceState.isStarting, + ProxyServiceState.isRunning, + ) { isStarting, isRunning -> + isStarting to isRunning + }.collect { (isStarting, isRunning) -> binding.statusValue.text = getString( - if (isRunning) R.string.status_running else R.string.status_stopped, + when { + isStarting -> R.string.status_starting + isRunning -> R.string.status_running + else -> R.string.status_stopped + }, ) - binding.startButton.isEnabled = !isRunning - binding.stopButton.isEnabled = isRunning + binding.startButton.isEnabled = !isStarting && !isRunning + binding.stopButton.isEnabled = isStarting || isRunning } } } lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { - ProxyServiceState.activeConfig.collect { config -> + combine( + ProxyServiceState.activeConfig, + ProxyServiceState.isStarting, + ) { config, isStarting -> + config to isStarting + }.collect { (config, isStarting) -> binding.serviceHint.text = if (config == null) { getString(R.string.service_hint_idle) + } else if (isStarting) { + getString( + R.string.service_hint_starting, + config.host, + config.port, + ) } else { getString( R.string.service_hint_running, @@ -114,6 +135,22 @@ class MainActivity : AppCompatActivity() { } } } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + ProxyServiceState.lastError.collect { error -> + if (error.isNullOrBlank()) { + if (!binding.errorText.isVisible) { + return@collect + } + binding.errorText.isVisible = false + } else { + binding.errorText.text = error + binding.errorText.isVisible = true + } + } + } + } } private fun requestNotificationPermissionIfNeeded() { diff --git a/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyForegroundService.kt b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyForegroundService.kt index fa71c27..8d060bc 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyForegroundService.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyForegroundService.kt @@ -9,9 +9,15 @@ import android.content.Intent import android.os.Build import android.os.IBinder import androidx.core.app.NotificationCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch class ProxyForegroundService : Service() { private lateinit var settingsStore: ProxySettingsStore + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) override fun onCreate() { super.onCreate() @@ -22,19 +28,31 @@ class ProxyForegroundService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { return when (intent?.action) { ACTION_STOP -> { - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() + ProxyServiceState.clearError() + serviceScope.launch { + stopProxyRuntime(removeNotification = true, stopService = true) + } START_NOT_STICKY } else -> { val config = settingsStore.load().validate().normalized if (config == null) { + ProxyServiceState.markFailed(getString(R.string.saved_config_invalid)) + stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() START_NOT_STICKY } else { - ProxyServiceState.markStarted(config) - startForeground(NOTIFICATION_ID, buildNotification(config)) + ProxyServiceState.markStarting(config) + startForeground( + NOTIFICATION_ID, + buildNotification( + getString(R.string.notification_starting, config.host, config.port), + ), + ) + serviceScope.launch { + startProxyRuntime(config) + } START_STICKY } } @@ -42,14 +60,15 @@ class ProxyForegroundService : Service() { } override fun onDestroy() { + serviceScope.cancel() + runCatching { PythonProxyBridge.stop(this) } ProxyServiceState.markStopped() super.onDestroy() } override fun onBind(intent: Intent?): IBinder? = null - private fun buildNotification(config: NormalizedProxyConfig): Notification { - val contentText = "SOCKS5 ${config.host}:${config.port} • service shell active" + private fun buildNotification(contentText: String): Notification { return NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle(getString(R.string.notification_title)) .setContentText(contentText) @@ -59,6 +78,40 @@ class ProxyForegroundService : Service() { .build() } + private suspend fun startProxyRuntime(config: NormalizedProxyConfig) { + val result = runCatching { + PythonProxyBridge.start(this, config) + } + + result.onSuccess { + ProxyServiceState.markStarted(config) + updateNotification(getString(R.string.notification_running, config.host, config.port)) + }.onFailure { error -> + ProxyServiceState.markFailed( + error.message ?: getString(R.string.proxy_start_failed_generic), + ) + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + } + + private fun stopProxyRuntime(removeNotification: Boolean, stopService: Boolean) { + runCatching { PythonProxyBridge.stop(this) } + ProxyServiceState.markStopped() + + if (removeNotification) { + stopForeground(STOP_FOREGROUND_REMOVE) + } + if (stopService) { + stopSelf() + } + } + + private fun updateNotification(contentText: String) { + val manager = getSystemService(NotificationManager::class.java) + manager.notify(NOTIFICATION_ID, buildNotification(contentText)) + } + private fun createNotificationChannel() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { return diff --git a/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyServiceState.kt b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyServiceState.kt index 1488c9c..ff08fc3 100644 --- a/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyServiceState.kt +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/ProxyServiceState.kt @@ -7,16 +7,43 @@ object ProxyServiceState { private val _isRunning = MutableStateFlow(false) val isRunning: StateFlow = _isRunning + private val _isStarting = MutableStateFlow(false) + val isStarting: StateFlow = _isStarting + private val _activeConfig = MutableStateFlow(null) val activeConfig: StateFlow = _activeConfig + private val _lastError = MutableStateFlow(null) + val lastError: StateFlow = _lastError + + fun markStarting(config: NormalizedProxyConfig) { + _activeConfig.value = config + _isStarting.value = true + _isRunning.value = false + _lastError.value = null + } + fun markStarted(config: NormalizedProxyConfig) { _activeConfig.value = config + _isStarting.value = false _isRunning.value = true + _lastError.value = null + } + + fun markFailed(message: String) { + _activeConfig.value = null + _isStarting.value = false + _isRunning.value = false + _lastError.value = message } fun markStopped() { _activeConfig.value = null + _isStarting.value = false _isRunning.value = false } + + fun clearError() { + _lastError.value = null + } } diff --git a/android/app/src/main/java/org/flowseal/tgwsproxy/PythonProxyBridge.kt b/android/app/src/main/java/org/flowseal/tgwsproxy/PythonProxyBridge.kt new file mode 100644 index 0000000..b5b9f52 --- /dev/null +++ b/android/app/src/main/java/org/flowseal/tgwsproxy/PythonProxyBridge.kt @@ -0,0 +1,39 @@ +package org.flowseal.tgwsproxy + +import android.content.Context +import com.chaquo.python.Python +import com.chaquo.python.android.AndroidPlatform +import java.io.File + +object PythonProxyBridge { + private const val MODULE_NAME = "android_proxy_bridge" + + fun start(context: Context, config: NormalizedProxyConfig): String { + val module = getModule(context) + return module.callAttr( + "start_proxy", + File(context.filesDir, "tg-ws-proxy").absolutePath, + config.host, + config.port, + config.dcIpList, + config.verbose, + ).toString() + } + + fun stop(context: Context) { + if (!Python.isStarted()) { + return + } + getModule(context).callAttr("stop_proxy") + } + + private fun getModule(context: Context) = + getPython(context.applicationContext).getModule(MODULE_NAME) + + private fun getPython(context: Context): Python { + if (!Python.isStarted()) { + Python.start(AndroidPlatform(context)) + } + return Python.getInstance() + } +} diff --git a/android/app/src/main/python/android_proxy_bridge.py b/android/app/src/main/python/android_proxy_bridge.py new file mode 100644 index 0000000..1ade094 --- /dev/null +++ b/android/app/src/main/python/android_proxy_bridge.py @@ -0,0 +1,92 @@ +import os +import threading +import time +from pathlib import Path +from typing import Iterable, Optional + +from proxy.app_runtime import ProxyAppRuntime + + +_RUNTIME_LOCK = threading.RLock() +_RUNTIME: Optional[ProxyAppRuntime] = None +_LAST_ERROR: Optional[str] = None + + +def _remember_error(message: str) -> None: + global _LAST_ERROR + _LAST_ERROR = message + + +def _normalize_dc_ip_list(dc_ip_list: Iterable[object]) -> list[str]: + return [str(item).strip() for item in dc_ip_list if str(item).strip()] + + +def start_proxy(app_dir: str, host: str, port: int, + dc_ip_list: Iterable[object], verbose: bool = False) -> str: + global _RUNTIME, _LAST_ERROR + + with _RUNTIME_LOCK: + if _RUNTIME is not None: + _RUNTIME.stop_proxy() + _RUNTIME = None + + _LAST_ERROR = None + os.environ["TG_WS_PROXY_CRYPTO_BACKEND"] = "python" + + runtime = ProxyAppRuntime( + Path(app_dir), + logger_name="tg-ws-android", + on_error=_remember_error, + ) + runtime.reset_log_file() + runtime.setup_logging(verbose=verbose) + + config = { + "host": host, + "port": int(port), + "dc_ip": _normalize_dc_ip_list(dc_ip_list), + "verbose": bool(verbose), + } + runtime.save_config(config) + + if not runtime.start_proxy(config): + _RUNTIME = None + raise RuntimeError(_LAST_ERROR or "Failed to start proxy runtime.") + + _RUNTIME = runtime + + # Give the proxy thread a short warm-up window so immediate bind failures + # surface before Kotlin reports the service as running. + for _ in range(10): + time.sleep(0.1) + with _RUNTIME_LOCK: + if _LAST_ERROR: + runtime.stop_proxy() + _RUNTIME = None + raise RuntimeError(_LAST_ERROR) + if runtime.is_proxy_running(): + return str(runtime.log_file) + + with _RUNTIME_LOCK: + runtime.stop_proxy() + _RUNTIME = None + raise RuntimeError("Proxy runtime did not become ready in time.") + + +def stop_proxy() -> None: + global _RUNTIME, _LAST_ERROR + + with _RUNTIME_LOCK: + _LAST_ERROR = None + if _RUNTIME is not None: + _RUNTIME.stop_proxy() + _RUNTIME = None + + +def is_running() -> bool: + with _RUNTIME_LOCK: + return bool(_RUNTIME and _RUNTIME.is_proxy_running()) + + +def get_last_error() -> Optional[str]: + return _LAST_ERROR diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index d124aa2..85b1c1c 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,12 +1,14 @@ TG WS Proxy - Android shell for the local Telegram SOCKS5 proxy. The embedded Python runtime will be wired in the next commit. + Android app for the local Telegram SOCKS5 proxy. Foreground service + Starting Running Stopped - The Android service shell is ready. Save settings, then start the service. - Foreground service active for %1$s:%2$d. Python proxy bootstrap will be connected next. + Configure the proxy settings, then start the foreground service. + Starting embedded Python proxy for %1$s:%2$d. + Foreground service active for %1$s:%2$d. Proxy host Proxy port DC to IP mappings (one DC:IP per line) @@ -19,4 +21,8 @@ TG WS Proxy Proxy service Keeps the Telegram proxy service alive in the foreground. + SOCKS5 %1$s:%2$d • starting embedded Python + SOCKS5 %1$s:%2$d • proxy active + Saved proxy settings are invalid. + Failed to start embedded Python proxy. diff --git a/android/build-local-debug.sh b/android/build-local-debug.sh index 139e58c..46312b0 100644 --- a/android/build-local-debug.sh +++ b/android/build-local-debug.sh @@ -44,6 +44,51 @@ fi ATTEMPTS="${ATTEMPTS:-5}" SLEEP_SECONDS="${SLEEP_SECONDS:-15}" TASK="${1:-assembleDebug}" +LOCAL_CHAQUOPY_REPO="${LOCAL_CHAQUOPY_REPO:-$ROOT_DIR/.m2-chaquopy}" +CHAQUOPY_MAVEN_BASE="${CHAQUOPY_MAVEN_BASE:-https://repo.maven.apache.org/maven2}" + +prefetch_artifact() { + local relative_path="$1" + local destination="$LOCAL_CHAQUOPY_REPO/$relative_path" + + if [[ -f "$destination" ]]; then + return 0 + fi + + mkdir -p "$(dirname "$destination")" + echo "Prefetching $relative_path" + curl \ + --fail \ + --location \ + --retry 8 \ + --retry-all-errors \ + --continue-at - \ + --connect-timeout 15 \ + --speed-limit 1024 \ + --speed-time 20 \ + --max-time 90 \ + --output "$destination" \ + "$CHAQUOPY_MAVEN_BASE/$relative_path" +} + +prefetch_chaquopy_runtime() { + local artifacts=( + "com/chaquo/python/runtime/chaquopy_java/17.0.0/chaquopy_java-17.0.0.pom" + "com/chaquo/python/runtime/chaquopy_java/17.0.0/chaquopy_java-17.0.0.jar" + "com/chaquo/python/runtime/libchaquopy_java/17.0.0/libchaquopy_java-17.0.0.pom" + "com/chaquo/python/runtime/libchaquopy_java/17.0.0/libchaquopy_java-17.0.0-3.12-arm64-v8a.so" + "com/chaquo/python/runtime/libchaquopy_java/17.0.0/libchaquopy_java-17.0.0-3.12-x86_64.so" + "com/chaquo/python/target/3.12.12-0/target-3.12.12-0.pom" + "com/chaquo/python/target/3.12.12-0/target-3.12.12-0-arm64-v8a.zip" + "com/chaquo/python/target/3.12.12-0/target-3.12.12-0-x86_64.zip" + ) + + for artifact in "${artifacts[@]}"; do + prefetch_artifact "$artifact" + done +} + +prefetch_chaquopy_runtime for attempt in $(seq 1 "$ATTEMPTS"); do echo "==> Android build attempt $attempt/$ATTEMPTS ($TASK)" diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 017d909..bbaf05f 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -1,4 +1,5 @@ plugins { id("com.android.application") version "8.5.2" apply false + id("com.chaquo.python") version "17.0.0" apply false id("org.jetbrains.kotlin.android") version "1.9.24" apply false } diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 94a30ff..050f305 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -1,5 +1,10 @@ pluginManagement { repositories { + val localChaquopyRepo = file(System.getenv("LOCAL_CHAQUOPY_REPO") ?: ".m2-chaquopy") + if (localChaquopyRepo.isDirectory) { + maven(url = localChaquopyRepo.toURI()) + } + maven("https://chaquo.com/maven") gradlePluginPortal() google() mavenCentral() @@ -9,6 +14,11 @@ pluginManagement { dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { + val localChaquopyRepo = file(System.getenv("LOCAL_CHAQUOPY_REPO") ?: ".m2-chaquopy") + if (localChaquopyRepo.isDirectory) { + maven(url = localChaquopyRepo.toURI()) + } + maven("https://chaquo.com/maven") google() mavenCentral() } diff --git a/proxy/__init__.py b/proxy/__init__.py new file mode 100644 index 0000000..868f333 --- /dev/null +++ b/proxy/__init__.py @@ -0,0 +1 @@ +"""TG WS Proxy core package.""" diff --git a/proxy/app_runtime.py b/proxy/app_runtime.py index 3710723..bdeaa34 100644 --- a/proxy/app_runtime.py +++ b/proxy/app_runtime.py @@ -80,11 +80,20 @@ class ProxyAppRuntime: root = logging.getLogger() root.setLevel(logging.DEBUG if verbose else logging.INFO) + for handler in list(root.handlers): + if getattr(handler, "_tg_ws_proxy_runtime_handler", False): + root.removeHandler(handler) + try: + handler.close() + except Exception: + pass + fh = logging.FileHandler(str(self.log_file), encoding="utf-8") fh.setLevel(logging.DEBUG) fh.setFormatter(logging.Formatter( "%(asctime)s %(levelname)-5s %(name)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S")) + fh._tg_ws_proxy_runtime_handler = True root.addHandler(fh) if not getattr(sys, "frozen", False): @@ -93,6 +102,7 @@ class ProxyAppRuntime: ch.setFormatter(logging.Formatter( "%(asctime)s %(levelname)-5s %(message)s", datefmt="%H:%M:%S")) + ch._tg_ws_proxy_runtime_handler = True root.addHandler(ch) def prepare(self) -> dict: @@ -168,3 +178,6 @@ class ProxyAppRuntime: self.stop_proxy() time.sleep(delay_seconds) return self.start_proxy() + + def is_proxy_running(self) -> bool: + return bool(self._proxy_thread and self._proxy_thread.is_alive()) From fe55624e2465f61207b3302656fd92c1a574a046 Mon Sep 17 00:00:00 2001 From: Dark-Avery Date: Mon, 16 Mar 2026 21:19:50 +0300 Subject: [PATCH 8/8] feat(ci): build and publish Android debug APK in GitHub releases --- .github/workflows/build.yml | 56 ++++++++++++++++++++++++++++++++++-- android/build-local-debug.sh | 2 ++ proxy/app_runtime.py | 3 +- 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9887746..7c7f113 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -40,6 +40,51 @@ jobs: path: | dist/TgWsProxy.exe + build-android: + runs-on: ubuntu-latest + timeout-minutes: 30 + defaults: + run: + working-directory: android + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + cache: gradle + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + + - name: Accept Android SDK licenses + run: yes | sdkmanager --licenses > /dev/null + + - name: Install Android SDK packages + run: sdkmanager "platforms;android-34" "build-tools;34.0.0" + + - name: Build Android debug APK + run: | + chmod +x gradlew build-local-debug.sh + LOCAL_CHAQUOPY_REPO="$GITHUB_WORKSPACE/android/.m2-chaquopy-ci" ./build-local-debug.sh + + - name: Rename APK + run: cp app/build/outputs/apk/debug/app-debug.apk app/build/outputs/apk/debug/tg-ws-proxy-android-debug.apk + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: TgWsProxy-android-debug + path: android/app/build/outputs/apk/debug/tg-ws-proxy-android-debug.apk + build-win7: runs-on: windows-latest steps: @@ -71,7 +116,7 @@ jobs: path: dist/TgWsProxy-win7.exe release: - needs: [build, build-win7] + needs: [build, build-win7, build-android] runs-on: ubuntu-latest steps: - name: Download main build @@ -86,6 +131,12 @@ jobs: name: TgWsProxy-win7 path: dist + - name: Download Android build + uses: actions/download-artifact@v4 + with: + name: TgWsProxy-android-debug + path: dist + - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: @@ -96,7 +147,8 @@ jobs: files: | dist/TgWsProxy.exe dist/TgWsProxy-win7.exe + dist/tg-ws-proxy-android-debug.apk draft: false prerelease: false env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/android/build-local-debug.sh b/android/build-local-debug.sh index 46312b0..402fb06 100644 --- a/android/build-local-debug.sh +++ b/android/build-local-debug.sh @@ -80,6 +80,8 @@ prefetch_chaquopy_runtime() { "com/chaquo/python/runtime/libchaquopy_java/17.0.0/libchaquopy_java-17.0.0-3.12-x86_64.so" "com/chaquo/python/target/3.12.12-0/target-3.12.12-0.pom" "com/chaquo/python/target/3.12.12-0/target-3.12.12-0-arm64-v8a.zip" + "com/chaquo/python/target/3.12.12-0/target-3.12.12-0-stdlib-pyc.zip" + "com/chaquo/python/target/3.12.12-0/target-3.12.12-0-stdlib.zip" "com/chaquo/python/target/3.12.12-0/target-3.12.12-0-x86_64.zip" ) diff --git a/proxy/app_runtime.py b/proxy/app_runtime.py index bdeaa34..89e3450 100644 --- a/proxy/app_runtime.py +++ b/proxy/app_runtime.py @@ -36,7 +36,8 @@ class ProxyAppRuntime: self.default_config = dict(default_config or DEFAULT_CONFIG) self.log = logging.getLogger(logger_name) self.on_error = on_error - self.parse_dc_ip_list = parse_dc_ip_list or tg_ws_proxy.parse_dc_ip_list + self.parse_dc_ip_list = parse_dc_ip_list or \ + tg_ws_proxy.parse_dc_ip_list self.run_proxy = run_proxy or tg_ws_proxy._run self.thread_factory = thread_factory or threading.Thread self.config: dict = {}