diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml deleted file mode 100644 index bef67fb..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: πŸ› ΠŸΡ€ΠΎΠ±Π»Π΅ΠΌΠ° -title: '[ΠŸΡ€ΠΎΠ±Π»Π΅ΠΌΠ°] ' -description: Π‘ΠΎΠΎΠ±Ρ‰ΠΈΡ‚ΡŒ ΠΎ ΠΏΡ€ΠΎΠ±Π»Π΅ΠΌΠ΅ -labels: ['type: ΠΏΡ€ΠΎΠ±Π»Π΅ΠΌΠ°', 'status: нуТдаСтся Π² сортировкС'] - -body: - - type: textarea - id: description - attributes: - label: ΠžΠΏΠΈΡˆΠΈΡ‚Π΅ Π²Π°ΡˆΡƒ ΠΏΡ€ΠΎΠ±Π»Π΅ΠΌΡƒ - description: Π§Ρ‘Ρ‚ΠΊΠΎ ΠΎΠΏΠΈΡˆΠΈΡ‚Π΅ ΠΏΡ€ΠΎΠ±Π»Π΅ΠΌΡƒ с ΠΊΠΎΡ‚ΠΎΡ€ΠΎΠΉ Π²Ρ‹ ΡΡ‚ΠΎΠ»ΠΊΠ½ΡƒΠ»ΠΈΡΡŒ - placeholder: ОписаниС ΠΏΡ€ΠΎΠ±Π»Π΅ΠΌΡ‹ - validations: - required: true - - - type: textarea - id: additions - attributes: - label: Π”ΠΎΠΏΠΎΠ»Π½ΠΈΡ‚Π΅Π»ΡŒΠ½Ρ‹Π΅ Π΄Π΅Ρ‚Π°Π»ΠΈ - description: Если Ρƒ вас ΠΏΡ€ΠΎΠ±Π»Π΅ΠΌΡ‹ с Ρ€Π°Π±ΠΎΡ‚ΠΎΠΉ прокси, Ρ‚ΠΎ ΠΏΡ€ΠΈΠ»ΠΎΠΆΠΈΡ‚Π΅ Ρ„Π°ΠΉΠ» Π»ΠΎΠ³ΠΎΠ² Π² ΠΌΠΎΠΌΠ΅Π½Ρ‚ возникновСния ΠΏΡ€ΠΎΠ±Π»Π΅ΠΌΡ‹. \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index a44eb7d..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,349 +0,0 @@ -name: Build & Release - -on: - workflow_dispatch: - inputs: - make_release: - description: 'Create Github Release?' - type: boolean - required: true - default: false - version: - description: "Release version tag (e.g. v1.0.0)" - required: false - default: "v1.0.0" - -permissions: - contents: write - -jobs: - build-windows: - runs-on: windows-latest - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: "3.12" - cache: "pip" - - - name: Install dependencies - run: pip install . - - - name: Install pyinstaller - run: pip install "pyinstaller==6.13.0" - - - name: Build EXE with PyInstaller - run: pyinstaller packaging/windows.spec --noconfirm - - - name: Rename artifact - run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows.exe - - - name: Upload artifact - uses: actions/upload-artifact@v7 - with: - name: TgWsProxy - path: dist/TgWsProxy_windows.exe - - build-win7: - runs-on: windows-latest - strategy: - matrix: - include: - - arch: x64 - suffix: 64bit - - arch: x86 - suffix: 32bit - steps: - - uses: actions/checkout@v6 - - - uses: actions/setup-python@v6 - with: - python-version: "3.8" - architecture: ${{ matrix.arch }} - cache: "pip" - - - name: Install dependencies & pyinstaller - run: pip install . "pyinstaller==5.13.2" - - - name: Build EXE with PyInstaller - run: pyinstaller packaging/windows.spec --noconfirm - - - name: Rename artifact - run: mv dist/TgWsProxy.exe dist/TgWsProxy_windows_7_${{ matrix.suffix }}.exe - - - name: Upload artifact - uses: actions/upload-artifact@v7 - with: - name: TgWsProxy-win7-${{ matrix.suffix }} - path: dist/TgWsProxy_windows_7_${{ matrix.suffix }}.exe - - build-macos: - runs-on: macos-latest - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Install universal2 Python - run: | - set -euo pipefail - curl -LO https://www.python.org/ftp/python/3.12.10/python-3.12.10-macos11.pkg - sudo installer -pkg python-3.12.10-macos11.pkg -target / - echo "/Library/Frameworks/Python.framework/Versions/3.12/bin" >> "$GITHUB_PATH" - - - name: Install dependencies - run: | - set -euo pipefail - python3.12 -m pip install --upgrade pip setuptools wheel - python3.12 -m pip install delocate==0.13.0 - - mkdir -p wheelhouse/arm64 wheelhouse/x86_64 wheelhouse/universal2 - - python3.12 -m pip download \ - --only-binary=:all: \ - --platform macosx_11_0_arm64 \ - --python-version 3.12 \ - --implementation cp \ - -d wheelhouse/arm64 \ - 'cffi>=2.0.0' \ - Pillow==12.1.0 \ - psutil==7.0.0 - - python3.12 -m pip download \ - --only-binary=:all: \ - --platform macosx_10_13_x86_64 \ - --python-version 3.12 \ - --implementation cp \ - -d wheelhouse/x86_64 \ - 'cffi>=2.0.0' \ - Pillow==12.1.0 - - python3.12 -m pip download \ - --only-binary=:all: \ - --platform macosx_10_9_x86_64 \ - --python-version 3.12 \ - --implementation cp \ - -d wheelhouse/x86_64 \ - psutil==7.0.0 - - delocate-merge \ - wheelhouse/arm64/cffi-*.whl \ - wheelhouse/x86_64/cffi-*.whl \ - -w wheelhouse/universal2 - - delocate-merge \ - wheelhouse/arm64/pillow-12.1.0-*.whl \ - wheelhouse/x86_64/pillow-12.1.0-*.whl \ - -w wheelhouse/universal2 - - delocate-merge \ - wheelhouse/arm64/psutil-7.0.0-*.whl \ - wheelhouse/x86_64/psutil-7.0.0-*.whl \ - -w wheelhouse/universal2 - - python3.12 -m pip install --no-deps wheelhouse/universal2/*.whl - python3.12 -m pip install . - python3.12 -m pip install pyinstaller==6.13.0 - - - name: Create macOS icon from ICO - 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 - - - name: Build app with PyInstaller - run: python3.12 -m PyInstaller packaging/macos.spec --noconfirm - - - name: Validate universal2 app bundle - run: | - set -euo pipefail - found=0 - while IFS= read -r -d '' file; do - if file "$file" | grep -q "Mach-O"; then - found=1 - archs="$(lipo -archs "$file" 2>/dev/null || true)" - case "$archs" in - *arm64*x86_64*|*x86_64*arm64*) ;; - *) - echo "Missing universal2 slices in $file: ${archs:-unknown}" >&2 - exit 1 - ;; - esac - fi - done < <(find "dist/TG WS Proxy.app" -type f -print0) - - if [ "$found" -eq 0 ]; then - echo "No Mach-O files found in app bundle" >&2 - exit 1 - fi - - - 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 \ - "dist/TgWsProxy_macos_universal.dmg" - - rm -rf "$DMG_TEMP" - - - name: Upload artifact - uses: actions/upload-artifact@v7 - with: - name: TgWsProxy-macOS - path: dist/TgWsProxy_macos_universal.dmg - - build-linux: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - python3-venv \ - python3-dev \ - python3-gi \ - gir1.2-ayatanaappindicator3-0.1 \ - python3-tk - - - name: Create venv with system site-packages - run: python3 -m venv --system-site-packages .venv - - - name: Install dependencies - run: | - .venv/bin/pip install --upgrade pip - .venv/bin/pip install . - .venv/bin/pip install "pyinstaller==6.13.0" - - - name: Build binary with PyInstaller - run: .venv/bin/pyinstaller packaging/linux.spec --noconfirm - - - name: Rename binary artifact - run: mv dist/TgWsProxy dist/TgWsProxy_linux_amd64 - - - name: Create .deb package - run: | - set -euo pipefail - VERSION="${{ github.event.inputs.version }}" - VERSION="${VERSION#v}" - PKG_ROOT="pkg" - - rm -rf "$PKG_ROOT" - mkdir -p \ - "$PKG_ROOT/DEBIAN" \ - "$PKG_ROOT/usr/bin" \ - "$PKG_ROOT/usr/share/applications" \ - "$PKG_ROOT/usr/share/icons/hicolor/256x256/apps" - - install -m 755 dist/TgWsProxy_linux_amd64 "$PKG_ROOT/usr/bin/tg-ws-proxy" - - .venv/bin/python - < "$PKG_ROOT/usr/share/applications/tg-ws-proxy.desktop" < "$PKG_ROOT/DEBIAN/control" < + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. +=========================================================================== MIT License Copyright (c) 2026 Flowseal diff --git a/README.md b/README.md index a44043b..9d75258 100644 --- a/README.md +++ b/README.md @@ -1,206 +1,49 @@ +# TG WS Proxy (Android) + +**Π›ΠΎΠΊΠ°Π»ΡŒΠ½Ρ‹ΠΉ SOCKS5-прокси** для Telegram Android, ΠΊΠΎΡ‚ΠΎΡ€Ρ‹ΠΉ **ускоряСт Ρ€Π°Π±ΠΎΡ‚Ρƒ Telegram**, пСрСнаправляя Ρ‚Ρ€Π°Ρ„ΠΈΠΊ Ρ‡Π΅Ρ€Π΅Π· WebSocket-соСдинСния. + +Π­Ρ‚ΠΎ ΠΌΠΎΠ±ΠΈΠ»ΡŒΠ½Ρ‹ΠΉ Ρ„ΠΎΡ€ΠΊ популярного SOCKS5/WS прокси, ΠΊΠ°Ρ€Π΄ΠΈΠ½Π°Π»ΡŒΠ½ΠΎ ΠΏΠ΅Ρ€Π΅Ρ€Π°Π±ΠΎΡ‚Π°Π½Π½Ρ‹ΠΉ для ΡƒΠ΄ΠΎΠ±Π½ΠΎΠ³ΠΎ использования Π½Π° смартфонах. + > [!CAUTION] -> -> ### РСакция антивирусов -> -> Windows Defender часто ΠΎΡˆΠΈΠ±ΠΎΡ‡Π½ΠΎ ΠΏΠΎΠΌΠ΅Ρ‡Π°Π΅Ρ‚ ΠΏΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ ΠΊΠ°ΠΊ **Wacatac**. -> Если Π²Ρ‹ Π½Π΅ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ ΡΠΊΠ°Ρ‡Π°Ρ‚ΡŒ ΠΈΠ·-Π·Π° Π±Π»ΠΎΠΊΠΈΡ€ΠΎΠ²ΠΊΠΈ, Ρ‚ΠΎ: -> -> 1) ΠŸΠΎΠΏΡ€ΠΎΠ±ΡƒΠΉΡ‚Π΅ ΡΠΊΠ°Ρ‡Π°Ρ‚ΡŒ Π²Π΅Ρ€ΡΠΈΡŽ win7 (ΠΎΠ½Π° Π½ΠΈΡ‡Π΅ΠΌ Π½Π΅ отличаСтся Π² ΠΏΠ»Π°Π½Π΅ Ρ„ΡƒΠ½ΠΊΡ†ΠΈΠΎΠ½Π°Π»Π°) -> 2) ΠžΡ‚ΠΊΠ»ΡŽΡ‡ΠΈΡ‚Π΅ антивирус Π½Π° врСмя скачивания, Π΄ΠΎΠ±Π°Π²ΡŒΡ‚Π΅ Ρ„Π°ΠΉΠ» Π² ΠΈΡΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΡ ΠΈ Π²ΠΊΠ»ΡŽΡ‡ΠΈΡ‚Π΅ ΠΎΠ±Ρ€Π°Ρ‚Π½ΠΎ -> -> **ВсСгда провСряйтС, Ρ‡Ρ‚ΠΎ скачиваСтС ΠΈΠ· ΠΈΠ½Ρ‚Π΅Ρ€Π½Π΅Ρ‚Π°, Ρ‚Π΅ΠΌ Π±ΠΎΠ»Π΅Π΅ ΠΈΠ· Π½Π΅ΠΏΡ€ΠΎΠ²Π΅Ρ€Π΅Π½Π½Ρ‹Ρ… источников. ВсСгда Π»ΡƒΡ‡ΡˆΠ΅ ΡΠΌΠΎΡ‚Ρ€Π΅Ρ‚ΡŒ Π½Π° Π΄Π΅Ρ‚Π΅ΠΊΡ‚Ρ‹ ΡˆΠΈΡ€ΠΎΠΊΠΎ извСстных антивирусов Π½Π° VirusTotal** +> ### πŸ”΄ Π’ΠΠ–ΠΠž: РСкомСндуСмая настройка ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΡ +> +> Для ΠΊΠΎΡ€Ρ€Π΅ΠΊΡ‚Π½ΠΎΠΉ Ρ€Π°Π±ΠΎΡ‚Ρ‹ ΠΈ ΠΎΠ±Ρ…ΠΎΠ΄Π° Π±Π»ΠΎΠΊΠΈΡ€ΠΎΠ²ΠΎΠΊ ΠΏΡ€ΠΎΠ²Π°ΠΉΠ΄Π΅Ρ€Π° Π½Π°ΡΡ‚ΠΎΡΡ‚Π΅Π»ΡŒΠ½ΠΎ рСкомСндуСтся **сначала ΠΏΠ°Ρ€Π°Π»Π»Π΅Π»ΡŒΠ½ΠΎ Π·Π°ΠΏΡƒΡΡ‚ΠΈΡ‚ΡŒ VPN**. +> Если этого Π½Π΅ ΡΠ΄Π΅Π»Π°Ρ‚ΡŒ, вмСсто скоростного соСдинСния ΠΏΠΎ **WebSocket** Π±ΡƒΠ΄Π΅Ρ‚ ΠΏΡ€ΠΎΠΈΡΡ…ΠΎΠ΄ΠΈΡ‚ΡŒ Ρ„ΠΎΠ»Π»Π±Π΅ΠΊ (fallback) Π½Π° ΠΎΠ±Ρ‹Ρ‡Π½ΠΎΠ΅ **TCP-соСдинСниС**, Ρ‡Ρ‚ΠΎ Π² ΠΎΠ±Ρ‰Π΅ΠΌ Π½Π΅ Π³Π°Ρ€Π°Π½Ρ‚ΠΈΡ€ΡƒΠ΅Ρ‚ Π½ΠΈΠΊΠ°ΠΊΠΈΡ… прССмущСств ΠΈ Ρ€Π°Π±ΠΎΡ‚Ρ‹ Π² Ρ†Π΅Π»ΠΎΠΌ. -# TG WS Proxy +## 🌟 Π§Ρ‚ΠΎ Π½ΠΎΠ²ΠΎΠ³ΠΎ Π² Android-вСрсии -**Π›ΠΎΠΊΠ°Π»ΡŒΠ½Ρ‹ΠΉ SOCKS5-прокси** для Telegram Desktop, ΠΊΠΎΡ‚ΠΎΡ€Ρ‹ΠΉ **ускоряСт Ρ€Π°Π±ΠΎΡ‚Ρƒ Telegram**, пСрСнаправляя Ρ‚Ρ€Π°Ρ„ΠΈΠΊ Ρ‡Π΅Ρ€Π΅Π· WebSocket-соСдинСния. Π”Π°Π½Π½Ρ‹Π΅ ΠΏΠ΅Ρ€Π΅Π΄Π°ΡŽΡ‚ΡΡ Π² Ρ‚ΠΎΠΌ ΠΆΠ΅ Π·Π°ΡˆΠΈΡ„Ρ€ΠΎΠ²Π°Π½Π½ΠΎΠΌ Π²ΠΈΠ΄Π΅, Π° для Ρ€Π°Π±ΠΎΡ‚Ρ‹ Π½Π΅ Π½ΡƒΠΆΠ½Ρ‹ сторонниС сСрвСра. +Π€ΡƒΠ½ΠΊΡ†ΠΈΠΈ управлСния вынСсСны Π² красивый ΠΈ ΡƒΠ΄ΠΎΠ±Π½Ρ‹ΠΉ **Material 3** интСрфСйс (Jetpack Compose). + +- **ΠŸΠΎΠ»Π½ΠΎΡ†Π΅Π½Π½Ρ‹ΠΉ UI:** Настройка ΠΏΠΎΡ€Ρ‚Π°, ΠΏΡƒΠ»Π° Π΄Π°Ρ‚Π°Ρ†Π΅Π½Ρ‚Ρ€ΠΎΠ² ΠΈ количСства WebSocket соСдинСний дСлаСтся Π² 2 ΠΊΠ»ΠΈΠΊΠ°. +- **Π˜Π½Ρ‚Π΅Π³Ρ€Π°Ρ†ΠΈΡ с Telegram:** Кнопка Β«ΠŸΡ€ΠΈΠΌΠ΅Π½ΠΈΡ‚ΡŒ Π² Ρ‚Π΅Π»Π΅Π³Ρ€Π°ΠΌΠΌΒ» автоматичСски настроит прокси Ρ‡Π΅Ρ€Π΅Π· систСму Π³Π»ΡƒΠ±ΠΎΠΊΠΈΡ… ссылок (`tg://socks`) для любого установлСнного ΠΊΠ»ΠΈΠ΅Π½Ρ‚Π° (AyuGram, Plus Messenger, NekoGram ΠΈ Π΄Ρ€.). +- **Π‘Ρ‚Π°Π±ΠΈΠ»ΡŒΠ½Π°Ρ Ρ€Π°Π±ΠΎΡ‚Π° Π² Ρ„ΠΎΠ½Π΅:** ΠŸΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅Ρ‚ Β«Π½Π΅ΡƒΠ±ΠΈΠ²Π°Π΅ΠΌΡ‹ΠΉΒ» `Foreground Service` ΠΈ ΡΠ°ΠΌΠΎΠΊΠΎΠ½Ρ‚Ρ€ΠΎΠ»ΡŒ Wakelock'ΠΎΠ², Ρ‡Ρ‚ΠΎΠ±Ρ‹ Android Π½Π΅ "Π΄ΡƒΡˆΠΈΠ»" прокси Π² спящСм Ρ€Π΅ΠΆΠΈΠΌΠ΅. +- **ВстроСнный просмотрщик Π»ΠΎΠ³ΠΎΠ²:** Π’ Ρ€Π΅Π°Π»ΡŒΠ½ΠΎΠΌ Π²Ρ€Π΅ΠΌΠ΅Π½ΠΈ ΠΎΡ‚ΠΎΠ±Ρ€Π°ΠΆΠ°ΡŽΡ‚ΡΡ Π»ΠΎΠ³ΠΈΠΈ Ρ€Π°Π±ΠΎΡ‚Ρ‹ для диагностики. +- **ДинамичСскиС Ρ†Π²Π΅Ρ‚Π° ΠΈ Ρ‚Π΅ΠΌΡ‹:** ΠŸΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΊΠ° свСтлой ΠΈ Ρ‚Π΅ΠΌΠ½ΠΎΠΉ Ρ‚Π΅ΠΌ, Π° Ρ‚Π°ΠΊΠΆΠ΅ Material You (Π² Android 12+). + +![MyCollages](https://github.com/user-attachments/assets/4477809b-c793-4b81-ae13-83907849a0ab) -image ## Как это Ρ€Π°Π±ΠΎΡ‚Π°Π΅Ρ‚ ``` -Telegram Desktop β†’ SOCKS5 (127.0.0.1:1080) β†’ TG WS Proxy β†’ WSS β†’ Telegram DC +Telegram Android β†’ SOCKS5 (ΠΏΠΎ ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ 127.0.0.1:1080) β†’ TG WS Proxy β†’ WSS β†’ Telegram DC ``` -1. ΠŸΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ ΠΏΠΎΠ΄Π½ΠΈΠΌΠ°Π΅Ρ‚ Π»ΠΎΠΊΠ°Π»ΡŒΠ½Ρ‹ΠΉ SOCKS5-прокси Π½Π° `127.0.0.1:1080` -2. ΠŸΠ΅Ρ€Π΅Ρ…Π²Π°Ρ‚Ρ‹Π²Π°Π΅Ρ‚ ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΡ ΠΊ IP-адрСсам Telegram -3. Π˜Π·Π²Π»Π΅ΠΊΠ°Π΅Ρ‚ DC ID ΠΈΠ· MTProto obfuscation init-ΠΏΠ°ΠΊΠ΅Ρ‚Π° -4. УстанавливаСт WebSocket (TLS) соСдинСниС ΠΊ ΡΠΎΠΎΡ‚Π²Π΅Ρ‚ΡΡ‚Π²ΡƒΡŽΡ‰Π΅ΠΌΡƒ DC Ρ‡Π΅Ρ€Π΅Π· Π΄ΠΎΠΌΠ΅Π½Ρ‹ Telegram -5. Если WS нСдоступСн (302 redirect) β€” автоматичСски ΠΏΠ΅Ρ€Π΅ΠΊΠ»ΡŽΡ‡Π°Π΅Ρ‚ΡΡ Π½Π° прямоС TCP-соСдинСниС +1. ΠŸΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ ΠΏΠΎΠ΄Π½ΠΈΠΌΠ°Π΅Ρ‚ Π»ΠΎΠΊΠ°Π»ΡŒΠ½Ρ‹ΠΉ SOCKS5-прокси срСдствами Π½Π°Ρ‚ΠΈΠ²Π½ΠΎΠ³ΠΎ Π΄Π²ΠΈΠΆΠΊΠ° Π½Π° языкС **Go**. +2. ΠŸΠ΅Ρ€Π΅Ρ…Π²Π°Ρ‚Ρ‹Π²Π°Π΅Ρ‚ ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΡ ΠΊ IP-адрСсам Telegram. +3. Π˜Π·Π²Π»Π΅ΠΊΠ°Π΅Ρ‚ DC ID ΠΈΠ· MTProto-ΠΏΠ°ΠΊΠ΅Ρ‚Π° ΠΈ устанавливаСт Π·Π°Ρ‰ΠΈΡ‰Π΅Π½Π½ΠΎΠ΅ WebSocket (TLS) соСдинСниС. +4. Π­Ρ„Ρ„Π΅ΠΊΡ‚ΠΈΠ²Π½ΠΎ ΠΌΡƒΠ»ΡŒΡ‚ΠΈΠΏΠ»Π΅ΠΊΡΠΈΡ€ΡƒΠ΅Ρ‚ Ρ‚Ρ€Π°Ρ„ΠΈΠΊ. ## πŸš€ Быстрый старт -### Windows - -ΠŸΠ΅Ρ€Π΅ΠΉΠ΄ΠΈΡ‚Π΅ Π½Π° [страницу Ρ€Π΅Π»ΠΈΠ·ΠΎΠ²](https://github.com/Flowseal/tg-ws-proxy/releases) ΠΈ скачайтС **`TgWsProxy_windows.exe`**. Он собираСтся автоматичСски Ρ‡Π΅Ρ€Π΅Π· [Github Actions](https://github.com/Flowseal/tg-ws-proxy/actions) ΠΈΠ· ΠΎΡ‚ΠΊΡ€Ρ‹Ρ‚ΠΎΠ³ΠΎ исходного ΠΊΠΎΠ΄Π°. - -ΠŸΡ€ΠΈ ΠΏΠ΅Ρ€Π²ΠΎΠΌ запускС откроСтся ΠΎΠΊΠ½ΠΎ с инструкциСй ΠΏΠΎ ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΡŽ Telegram Desktop. ΠŸΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ сворачиваСтся Π² систСмный Ρ‚Ρ€Π΅ΠΉ. - -**МСню трСя:** - -- **ΠžΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ Π² Telegram** β€” автоматичСски Π½Π°ΡΡ‚Ρ€ΠΎΠΈΡ‚ΡŒ прокси Ρ‡Π΅Ρ€Π΅Π· `tg://socks` ссылку -- **ΠŸΠ΅Ρ€Π΅Π·Π°ΠΏΡƒΡΡ‚ΠΈΡ‚ΡŒ прокси** β€” пСрСзапуск Π±Π΅Π· Π²Ρ‹Ρ…ΠΎΠ΄Π° ΠΈΠ· прилоТСния -- **Настройки...** β€” GUI-Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΎΡ€ ΠΊΠΎΠ½Ρ„ΠΈΠ³ΡƒΡ€Π°Ρ†ΠΈΠΈ -- **ΠžΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ Π»ΠΎΠ³ΠΈ** β€” ΠΎΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ Ρ„Π°ΠΉΠ» Π»ΠΎΠ³ΠΎΠ² -- **Π’Ρ‹Ρ…ΠΎΠ΄** β€” ΠΎΡΡ‚Π°Π½ΠΎΠ²ΠΈΡ‚ΡŒ прокси ΠΈ Π·Π°ΠΊΡ€Ρ‹Ρ‚ΡŒ ΠΏΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ - -### macOS - -ΠŸΠ΅Ρ€Π΅ΠΉΠ΄ΠΈΡ‚Π΅ Π½Π° [страницу Ρ€Π΅Π»ΠΈΠ·ΠΎΠ²](https://github.com/Flowseal/tg-ws-proxy/releases) ΠΈ скачайтС **`TgWsProxy_macos_universal.dmg`** β€” ΡƒΠ½ΠΈΠ²Π΅Ρ€ΡΠ°Π»ΡŒΠ½Π°Ρ сборка для Apple Silicon ΠΈ Intel. - -1. ΠžΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ ΠΎΠ±Ρ€Π°Π· -2. ΠŸΠ΅Ρ€Π΅Π½Π΅ΡΡ‚ΠΈ **TG WS Proxy.app** Π² ΠΏΠ°ΠΏΠΊΡƒ **Applications** -3. ΠŸΡ€ΠΈ ΠΏΠ΅Ρ€Π²ΠΎΠΌ запускС macOS ΠΌΠΎΠΆΠ΅Ρ‚ ΠΏΠΎΠΏΡ€ΠΎΡΠΈΡ‚ΡŒ ΠΏΠΎΠ΄Ρ‚Π²Π΅Ρ€Π΄ΠΈΡ‚ΡŒ ΠΎΡ‚ΠΊΡ€Ρ‹Ρ‚ΠΈΠ΅: **БистСмныС настройки β†’ ΠšΠΎΠ½Ρ„ΠΈΠ΄Π΅Π½Ρ†ΠΈΠ°Π»ΡŒΠ½ΠΎΡΡ‚ΡŒ ΠΈ Π±Π΅Π·ΠΎΠΏΠ°ΡΠ½ΠΎΡΡ‚ΡŒ β†’ Всё Ρ€Π°Π²Π½ΠΎ ΠΎΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ** - -### Linux - -Для Debian/Ubuntu скачайтС со [страницы Ρ€Π΅Π»ΠΈΠ·ΠΎΠ²](https://github.com/Flowseal/tg-ws-proxy/releases) ΠΏΠ°ΠΊΠ΅Ρ‚ **`TgWsProxy_linux_amd64.deb`**. - -Для Arch ΠΈ Arch-Based дистрибутивов ΠΏΠΎΠ΄Π³ΠΎΡ‚ΠΎΠ²Π»Π΅Π½Ρ‹ ΠΏΠ°ΠΊΠ΅Ρ‚Ρ‹ Π² AUR: [tg-ws-proxy-bin](https://aur.archlinux.org/packages/tg-ws-proxy-bin), [tg-ws-proxy-git](https://aur.archlinux.org/packages/tg-ws-proxy-git), [tg-ws-proxy-cli](https://aur.archlinux.org/packages/tg-ws-proxy-cli) - -```shell -# Установка Π±Π΅Π· AUR-helper -git clone https://aur.archlinux.org/tg-ws-proxy-bin.git -cd tg-ws-proxy-bin -makepkg -si - -# ΠŸΡ€ΠΈ ΠΏΠΎΠΌΠΎΡ‰ΠΈ AUR-helper -paru -S tg-ws-proxy-bin - -# Если Π²Ρ‹ установили -cli ΠΏΠ°ΠΊΠ΅Ρ‚, Ρ‚ΠΎ запуск осущСствляСтся Ρ‡Π΅Ρ€Π΅Π· systemctl, Π³Π΄Π΅ 8888 это Π½ΠΎΠΌΠ΅Ρ€ ΠΏΠΎΡ€Ρ‚Π° прокси: -sudo systemctl start tg-ws-proxy-cli@8888 -``` - -Для ΠΎΡΡ‚Π°Π»ΡŒΠ½Ρ‹Ρ… дистрибутивов ΠΌΠΎΠΆΠ½ΠΎ ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚ΡŒ **`TgWsProxy_linux_amd64`** (Π±ΠΈΠ½Π°Ρ€Π½Ρ‹ΠΉ Ρ„Π°ΠΉΠ» для x86_64). - -```bash -chmod +x TgWsProxy_linux_amd64 -./TgWsProxy_linux_amd64 -``` - -ΠŸΡ€ΠΈ ΠΏΠ΅Ρ€Π²ΠΎΠΌ запускС откроСтся ΠΎΠΊΠ½ΠΎ с инструкциСй. ΠŸΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ Ρ€Π°Π±ΠΎΡ‚Π°Π΅Ρ‚ Π² систСмном Ρ‚Ρ€Π΅Π΅ (трСбуСтся AppIndicator). - -## Установка ΠΈΠ· исходников - -### ΠšΠΎΠ½ΡΠΎΠ»ΡŒΠ½Ρ‹ΠΉ proxy - -Для запуска Ρ‚ΠΎΠ»ΡŒΠΊΠΎ SOCKS5/WebSocket proxy Π±Π΅Π· tray-интСрфСйса достаточно Π±Π°Π·ΠΎΠ²ΠΎΠΉ установки: - -```bash -pip install -e . -tg-ws-proxy -``` - -### Windows 7/10+ - -```bash -pip install -e . -tg-ws-proxy-tray-win -``` - -### macOS - -```bash -pip install -e . -tg-ws-proxy-tray-macos -``` - -### Linux - -```bash -pip install -e . -tg-ws-proxy-tray-linux -``` - -### ΠšΠΎΠ½ΡΠΎΠ»ΡŒΠ½Ρ‹ΠΉ Ρ€Π΅ΠΆΠΈΠΌ ΠΈΠ· исходников - -```bash -tg-ws-proxy [--port PORT] [--host HOST] [--dc-ip DC:IP ...] [-v] -``` - -**АргумСнты:** - -| АргумСнт | По ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ | ОписаниС | -|---|---|---| -| `--port` | `1080` | ΠŸΠΎΡ€Ρ‚ SOCKS5-прокси | -| `--host` | `127.0.0.1` | Π₯ост SOCKS5-прокси | -| `--dc-ip` | `2:149.154.167.220`, `4:149.154.167.220` | Π¦Π΅Π»Π΅Π²ΠΎΠΉ IP для DC (ΠΌΠΎΠΆΠ½ΠΎ ΡƒΠΊΠ°Π·Π°Ρ‚ΡŒ нСсколько Ρ€Π°Π·) | -| `-v`, `--verbose` | Π²Ρ‹ΠΊΠ». | ΠŸΠΎΠ΄Ρ€ΠΎΠ±Π½ΠΎΠ΅ Π»ΠΎΠ³ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ (DEBUG) | - -**ΠŸΡ€ΠΈΠΌΠ΅Ρ€Ρ‹:** - -```bash -# Π‘Ρ‚Π°Π½Π΄Π°Ρ€Ρ‚Π½Ρ‹ΠΉ запуск -tg-ws-proxy - -# Π”Ρ€ΡƒΠ³ΠΎΠΉ ΠΏΠΎΡ€Ρ‚ ΠΈ Π΄ΠΎΠΏΠΎΠ»Π½ΠΈΡ‚Π΅Π»ΡŒΠ½Ρ‹Π΅ DC -tg-ws-proxy --port 9050 --dc-ip 1:149.154.175.205 --dc-ip 2:149.154.167.220 - -# Π‘ ΠΏΠΎΠ΄Ρ€ΠΎΠ±Π½Ρ‹ΠΌ Π»ΠΎΠ³ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ΠΌ -tg-ws-proxy -v -``` - -## CLI-скрипты (pyproject.toml) - -CLI ΠΊΠΎΠΌΠ°Π½Π΄Ρ‹ ΠΎΠ±ΡŠΡΠ²Π»ΡΡŽΡ‚ΡΡ Π² `pyproject.toml` Π² сСкции `[project.scripts]` ΠΈ Π΄ΠΎΠ»ΠΆΠ½Ρ‹ ΡƒΠΊΠ°Π·Ρ‹Π²Π°Ρ‚ΡŒ Π½Π° `module:function`. - -ΠŸΡ€ΠΈΠΌΠ΅Ρ€: - -```toml -[project.scripts] -tg-ws-proxy = "proxy.tg_ws_proxy:main" -tg-ws-proxy-tray-win = "windows:main" -tg-ws-proxy-tray-macos = "macos:main" -tg-ws-proxy-tray-linux = "linux:main" -``` - -## Настройка Telegram Desktop - -### АвтоматичСски - -ПКМ ΠΏΠΎ ΠΈΠΊΠΎΠ½ΠΊΠ΅ Π² Ρ‚Ρ€Π΅Π΅ β†’ **Β«ΠžΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ Π² TelegramΒ»** - -### Π’Ρ€ΡƒΡ‡Π½ΡƒΡŽ - -1. Telegram β†’ **Настройки** β†’ **ΠŸΡ€ΠΎΠ΄Π²ΠΈΠ½ΡƒΡ‚Ρ‹Π΅ настройки** β†’ **Π’ΠΈΠΏ ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΡ** β†’ **ΠŸΡ€ΠΎΠΊΡΠΈ** -2. Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ прокси: - - **Π’ΠΈΠΏ:** SOCKS5 - - **Π‘Π΅Ρ€Π²Π΅Ρ€:** `127.0.0.1` - - **ΠŸΠΎΡ€Ρ‚:** `1080` - - **Π›ΠΎΠ³ΠΈΠ½/ΠŸΠ°Ρ€ΠΎΠ»ΡŒ:** ΠΎΡΡ‚Π°Π²ΠΈΡ‚ΡŒ пустыми - -## ΠšΠΎΠ½Ρ„ΠΈΠ³ΡƒΡ€Π°Ρ†ΠΈΡ - -Tray-ΠΏΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ Ρ…Ρ€Π°Π½ΠΈΡ‚ Π΄Π°Π½Π½Ρ‹Π΅ Π²: - -- **Windows:** `%APPDATA%/TgWsProxy` -- **macOS:** `~/Library/Application Support/TgWsProxy` -- **Linux:** `~/.config/TgWsProxy` (ΠΈΠ»ΠΈ `$XDG_CONFIG_HOME/TgWsProxy`) - -```json -{ - "port": 1080, - "dc_ip": [ - "2:149.154.167.220", - "4:149.154.167.220" - ], - "verbose": false -} -``` - -## АвтоматичСская сборка - -ΠŸΡ€ΠΎΠ΅ΠΊΡ‚ содСрТит спСцификации PyInstaller ([`packaging/windows.spec`](packaging/windows.spec), [`packaging/macos.spec`](packaging/macos.spec), [`packaging/linux.spec`](packaging/linux.spec)) ΠΈ GitHub Actions workflow ([`.github/workflows/build.yml`](.github/workflows/build.yml)) для автоматичСской сборки. - -Минимально ΠΏΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΈΠ²Π°Π΅ΠΌΡ‹Π΅ вСрсии ОБ для Ρ‚Π΅ΠΊΡƒΡ‰ΠΈΡ… Π±ΠΈΠ½Π°Ρ€Π½Ρ‹Ρ… сборок: - -- Windows 10+ для `TgWsProxy_windows.exe` -- Windows 7 (x64) для `TgWsProxy_windows_7_64bit.exe` -- Windows 7 (x32) для `TgWsProxy_windows_7_32bit.exe` -- Intel macOS 10.15+ -- Apple Silicon macOS 11.0+ -- Linux x86_64 (трСбуСтся AppIndicator для систСмного трСя) +1. ΠŸΠ΅Ρ€Π΅ΠΉΠ΄ΠΈΡ‚Π΅ Π½Π° **[страницу Ρ€Π΅Π»ΠΈΠ·ΠΎΠ²]** ΠΈ скачайтС Π°ΠΊΡ‚ΡƒΠ°Π»ΡŒΠ½Ρ‹ΠΉ `APK`-Ρ„Π°ΠΉΠ». +2. УстановитС ΠΏΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ Π½Π° ваш Android-смартфон. +3. ΠžΡ‚ΠΊΡ€ΠΎΠΉΡ‚Π΅ **TG WS Proxy**. +4. Π’Ρ‹Π±Π΅Ρ€ΠΈΡ‚Π΅ Ρ‚Ρ€Π΅Π±ΡƒΠ΅ΠΌΡ‹ΠΉ ΠΏΡƒΠ» IP-адрСсов. +5. *(РСкомСндуСтся)* **Π’ΠΊΠ»ΡŽΡ‡ΠΈΡ‚Π΅ VPN**. +6. НаТмитС **Β«Π—Π°ΠΏΡƒΡΡ‚ΠΈΡ‚ΡŒ прокси»** β€” появится ΡƒΠ²Π΅Π΄ΠΎΠΌΠ»Π΅Π½ΠΈΠ΅ ΠΎ Ρ€Π°Π±ΠΎΡ‚Π΅ Π² Ρ„ΠΎΠ½ΠΎΠ²ΠΎΠΌ Ρ€Π΅ΠΆΠΈΠΌΠ΅. +7. НаТмитС **Β«ΠŸΡ€ΠΈΠΌΠ΅Π½ΠΈΡ‚ΡŒ Π² Ρ‚Π΅Π»Π΅Π³Ρ€Π°ΠΌΠΌΒ»** β€” откроСтся ΠΊΠ»ΠΈΠ΅Π½Ρ‚ Telegram, останСтся Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Π½Π°ΠΆΠ°Ρ‚ΡŒ Β«ΠŸΠΎΠ΄ΠΊΠ»ΡŽΡ‡ΠΈΡ‚ΡŒΒ». ## ЛицСнзия -[MIT License](LICENSE) +Π­Ρ‚ΠΎΡ‚ Ρ„ΠΎΡ€ΠΊ распространяСтся ΠΏΠΎΠ΄ Π»ΠΈΡ†Π΅Π½Π·ΠΈΠ΅ΠΉ **GPLv3**. (ΠžΡ€ΠΈΠ³ΠΈΠ½Π°Π»ΡŒΠ½Ρ‹ΠΉ ΠΊΠΎΠ΄ `tg-ws-proxy` доступСн ΠΏΠΎΠ΄ MIT). Π€Π°ΠΉΠ» Π»ΠΈΡ†Π΅Π½Π·ΠΈΠΈ ΠΏΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ ΠΊ исходному ΠΊΠΎΠ΄Ρƒ. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..3f1f1c4 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,91 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.amurcanov.tgwsproxy" + compileSdk = 34 + + defaultConfig { + applicationId = "com.amurcanov.tgwsproxy" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + + ndk { + abiFilters.add("arm64-v8a") + } + } + + signingConfigs { + val keystoreFile = file("amurcanov.jks") + if (keystoreFile.exists()) { + create("release") { + storeFile = keystoreFile + // Π‘Π΅Ρ€Π΅ΠΌ ΠΏΠ°Ρ€ΠΎΠ»ΠΈ ΠΈΠ· Π»ΠΎΠΊΠ°Π»ΡŒΠ½Ρ‹Ρ… ΠΏΠ΅Ρ€Π΅ΠΌΠ΅Π½Π½Ρ‹Ρ… срСды ΠΈΠ»ΠΈ Ρ„Π°ΠΉΠ»Π° + storePassword = System.getenv("KEYSTORE_PASSWORD") ?: "flowseal-fork" + keyAlias = "amurcanov" + keyPassword = System.getenv("KEY_PASSWORD") ?: "flowseal-fork" + } + } + } + + buildTypes { + release { + signingConfig = signingConfigs.findByName("release") + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.8" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + sourceSets { + getByName("main") { + jniLibs.srcDir("src/main/jniLibs") + } + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0") + implementation("androidx.activity:activity-compose:1.8.2") + implementation(platform("androidx.compose:compose-bom:2024.01.00")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + + // JNA for easy C-shared library calls + implementation("net.java.dev.jna:jna:5.14.0@aar") + debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14") + implementation("androidx.compose.material:material-icons-extended") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..4dd3ef7 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,16 @@ +# Add project specific ProGuard rules here. + +-dontwarn java.awt.** +-dontwarn java.beans.** +-dontwarn javax.swing.** +-dontwarn com.sun.jna.** +# Keep JNA interfaces and methods from being removed or obfuscated +-keep class com.sun.jna.** { *; } +-keep interface com.sun.jna.Library { *; } + +# Keep our proxy library interface and NativeProxy object +-keep class com.amurcanov.tgwsproxy.NativeProxy { *; } +-keep interface com.amurcanov.tgwsproxy.ProxyLibrary { *; } +-keepclassmembers class * extends com.sun.jna.Library { + ; +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..333c95d --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/example/tgwsproxy/MainActivity.kt b/app/src/main/java/com/example/tgwsproxy/MainActivity.kt new file mode 100644 index 0000000..0616ad0 --- /dev/null +++ b/app/src/main/java/com/example/tgwsproxy/MainActivity.kt @@ -0,0 +1,585 @@ +package com.example.tgwsproxy + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.PowerManager +import android.provider.Settings +import android.content.ClipData +import android.content.ClipboardManager +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.* +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.NightsStay +import androidx.compose.material.icons.filled.WbSunny +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import java.io.BufferedReader +import java.io.InputStreamReader + +data class DataCenter(val name: String, val ips: String) + +val datacenters = listOf( + DataCenter("НидСрланды", "91.108.4.0/22,91.108.8.0/22,149.154.160.0/20"), + DataCenter("Ѐинляндия", "91.105.192.0/23,185.76.151.0/24"), + DataCenter("Π‘ΠΈΠ½Π³Π°ΠΏΡƒΡ€", "91.108.56.0/22,91.108.16.0/22"), + DataCenter("Россия", "91.108.12.0/22,91.108.20.0/22") +) + +val telegramApps = listOf( + "org.telegram.messenger", + "org.thunderdog.challegram", + "com.radolyn.ayugram", + "app.exteragram.messenger", + "ir.ilmili.telegraph", + "org.telegram.plus", + "tw.nekomimi.nekogram", + "tw.nekomimi.nekogramx", + "org.telegram.mdgram", + "com.iMe.android", + "app.nicegram", + "org.telegram.bgram", + "cc.modery.cherrygram", + "io.github.nextalone.nagram" +) + +class MainActivity : ComponentActivity() { + + private val requestPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { + // Ignored in this example, but handles Tiramisu+ notifications + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + checkBatteryOptimizations() + + setContent { + var isDarkTheme by remember { mutableStateOf(false) } + val context = LocalContext.current + + // Dynamic colors logic for Android 12+ (Material You) + val colorScheme = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + if (isDarkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + isDarkTheme -> darkColorScheme() + else -> lightColorScheme() + } + + MaterialTheme(colorScheme = colorScheme) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + ProxyScreen( + isDarkTheme = isDarkTheme, + onThemeChange = { isDarkTheme = !isDarkTheme } + ) + } + } + } + } + + private fun checkBatteryOptimizations() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val pm = getSystemService(Context.POWER_SERVICE) as PowerManager + if (!pm.isIgnoringBatteryOptimizations(packageName)) { + try { + val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) + intent.data = Uri.parse("package:$packageName") + startActivity(intent) + } catch (e: Exception) { + Toast.makeText(this, "НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ Π·Π°ΠΏΡ€ΠΎΡΠΈΡ‚ΡŒ Ρ€Π°Π±ΠΎΡ‚Ρƒ Π² Ρ„ΠΎΠ½Π΅", Toast.LENGTH_SHORT).show() + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProxyScreen(isDarkTheme: Boolean, onThemeChange: () -> Unit) { + val context = LocalContext.current + val isRunning by ProxyService.isRunning.collectAsStateWithLifecycle() + var selectedDc by remember { mutableStateOf(datacenters[0]) } + var showDcModal by remember { mutableStateOf(false) } + var portText by remember { mutableStateOf("1080") } + var selectedPoolSize by remember { mutableStateOf(4) } + var showLogs by rememberSaveable { mutableStateOf(true) } + + LaunchedEffect(showLogs) { + if (showLogs) LogManager.startListening() else LogManager.stopListening() + } + + val startProxyAction by rememberUpdatedState { + val port = portText.toIntOrNull() + if (port == null) { + Toast.makeText(context, "НСвСрный ΠΏΠΎΡ€Ρ‚", Toast.LENGTH_SHORT).show() + return@rememberUpdatedState + } + if (selectedDc == null) { + Toast.makeText(context, "Π’Ρ‹Π±Π΅Ρ€ΠΈΡ‚Π΅ ΠΏΡƒΠ» Π΄Π°Ρ‚Π°Ρ†Π΅Π½Ρ‚Ρ€ΠΎΠ²", Toast.LENGTH_SHORT).show() + return@rememberUpdatedState + } + + val startIntent = Intent(context, ProxyService::class.java).apply { + action = ProxyService.ACTION_START + putExtra(ProxyService.EXTRA_PORT, port) + putExtra(ProxyService.EXTRA_IPS, selectedDc!!.ips) + putExtra(ProxyService.EXTRA_POOL_SIZE, selectedPoolSize) + } + ContextCompat.startForegroundService(context, startIntent) + } + + val stopProxyAction by rememberUpdatedState { + val stopIntent = Intent(context, ProxyService::class.java).apply { + action = ProxyService.ACTION_STOP + } + context.startService(stopIntent) + } + + val applyInTelegramAction by rememberUpdatedState { + val port = portText.toIntOrNull() ?: 1080 + val proxyUrl = "tg://socks?server=127.0.0.1&port=$port" + openTelegram(context, proxyUrl) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Telegram WS Proxy", fontWeight = FontWeight.SemiBold) }, + actions = { + IconButton(onClick = onThemeChange) { + Crossfade(targetState = isDarkTheme, animationSpec = tween(400), label = "themeAnim") { isDark -> + if (isDark) { + Icon( + imageVector = Icons.Default.WbSunny, + contentDescription = "БвСтлая Ρ‚Π΅ΠΌΠ°", + tint = MaterialTheme.colorScheme.onSurface + ) + } else { + Icon( + imageVector = Icons.Default.NightsStay, + contentDescription = "ВСмная Ρ‚Π΅ΠΌΠ°", + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + titleContentColor = MaterialTheme.colorScheme.onSurface, + actionIconContentColor = MaterialTheme.colorScheme.onSurface + ) + ) + }, + containerColor = MaterialTheme.colorScheme.background + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center + ) { + // Constrain content width for tablets to look good anywhere + Column( + modifier = Modifier + .fillMaxHeight() + .widthIn(max = 600.dp) + .padding(horizontal = 24.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top // Push top fields higher + ) { + + // Proxy Port Input + OutlinedTextField( + value = portText, + onValueChange = { portText = it }, + label = { Text("ΠŸΠΎΡ€Ρ‚ прокси") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + shape = RoundedCornerShape(24.dp), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 10.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.onSurfaceVariant + ), + singleLine = true + ) + + // DC selection + Box( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + .clip(RoundedCornerShape(24.dp)) + .clickable { showDcModal = true } + ) { + OutlinedTextField( + value = selectedDc?.name ?: "", + onValueChange = {}, + label = { Text("ΠŸΡƒΠ» Π΄Π°Ρ‚Π°Ρ†Π΅Π½Ρ‚Ρ€ΠΎΠ²") }, + enabled = false, + shape = RoundedCornerShape(24.dp), + colors = OutlinedTextFieldDefaults.colors( + disabledTextColor = MaterialTheme.colorScheme.onSurface, + disabledLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledBorderColor = MaterialTheme.colorScheme.onSurfaceVariant + ), + modifier = Modifier.fillMaxWidth() + ) + } + + + + // Pool size selector + Text( + "Π Π°Π·ΠΌΠ΅Ρ€ ΠΏΡƒΠ»Π° WS", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp) + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + listOf(4, 6, 8).forEach { size -> + val isSelected = selectedPoolSize == size + FilledTonalButton( + onClick = { selectedPoolSize = size }, + enabled = !isRunning, + modifier = Modifier.weight(1f).height(48.dp), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, + contentColor = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant + ) + ) { + Text( + "$size", + style = MaterialTheme.typography.titleMedium, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal + ) + } + } + } + + // Proxy Start/Stop Button + AnimatedContent( + targetState = isRunning, + transitionSpec = { + fadeIn(animationSpec = tween(300)) togetherWith fadeOut(animationSpec = tween(300)) + }, + label = "runAnim" + ) { running -> + Button( + onClick = { + if (running) stopProxyAction() else startProxyAction() + }, + modifier = Modifier + .fillMaxWidth() + .height(64.dp), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (running) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary + ) + ) { + Text( + if (running) "ΠžΡΡ‚Π°Π½ΠΎΠ²ΠΈΡ‚ΡŒ прокси" else "Π—Π°ΠΏΡƒΡΡ‚ΠΈΡ‚ΡŒ прокси", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + // Apply in Telegram Button + FilledTonalButton( + onClick = applyInTelegramAction, + enabled = isRunning, + modifier = Modifier + .fillMaxWidth() + .height(64.dp), + shape = RoundedCornerShape(12.dp) + ) { + Text( + "ΠŸΡ€ΠΈΠΌΠ΅Π½ΠΈΡ‚ΡŒ Π² Ρ‚Π΅Π»Π΅Π³Ρ€Π°ΠΌΠΌ", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Logs toggle button β€” same style as main buttons + Button( + onClick = { showLogs = !showLogs }, + modifier = Modifier + .fillMaxWidth() + .height(64.dp), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ) + ) { + Text( + if (showLogs) "Π‘ΠΊΡ€Ρ‹Ρ‚ΡŒ Π»ΠΎΠ³ΠΈ" else "ΠŸΠΎΠΊΠ°Π·Π°Ρ‚ΡŒ Π»ΠΎΠ³ΠΈ", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + } + + if (showLogs) { + val logs by LogManager.logs.collectAsStateWithLifecycle() + val scroll = rememberScrollState() + val primaryColor = MaterialTheme.colorScheme.primary + + // Auto-scroll to bottom when new logs arrive + LaunchedEffect(logs.size) { + scroll.animateScrollTo(scroll.maxValue) + } + + Spacer(modifier = Modifier.height(12.dp)) + + Box(modifier = Modifier + .fillMaxWidth() + .weight(1f) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + ) { + Text( + text = logs.joinToString("\n") { formatLogLine(it) }, + modifier = Modifier + .fillMaxSize() + .padding(start = 12.dp, end = 40.dp, top = 12.dp, bottom = 12.dp) + .verticalScroll(scroll), + color = primaryColor, + style = MaterialTheme.typography.bodySmall, + lineHeight = MaterialTheme.typography.bodySmall.fontSize * 1.5 + ) + IconButton( + onClick = { + val cm = ContextCompat.getSystemService(context, ClipboardManager::class.java) + cm?.setPrimaryClip(ClipData.newPlainText("Logs", logs.joinToString("\n"))) + Toast.makeText(context, "Π›ΠΎΠ³ΠΈ скопированы!", Toast.LENGTH_SHORT).show() + }, + modifier = Modifier.align(Alignment.TopEnd).padding(4.dp) + ) { + Icon( + Icons.Default.ContentCopy, + "ΠšΠΎΠΏΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ Π»ΠΎΠ³ΠΈ", + tint = primaryColor.copy(alpha = 0.6f) + ) + } + } + } else { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } + + if (showDcModal) { + DcSelectionDialog( + currentValue = selectedDc, + onDismiss = { showDcModal = false }, + onSelect = { + selectedDc = it + showDcModal = false + } + ) + } +} + +@Composable +fun DcSelectionDialog( + currentValue: DataCenter?, + onDismiss: () -> Unit, + onSelect: (DataCenter) -> Unit +) { + val currentOnSelect by rememberUpdatedState(onSelect) + + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.widthIn(max = 400.dp) + ) { + Column(modifier = Modifier.padding(24.dp)) { + Text( + text = "ΠŸΡƒΠ» Π΄Π°Ρ‚Π°Ρ†Π΅Π½Ρ‚Ρ€ΠΎΠ²", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 20.dp), + fontWeight = FontWeight.SemiBold + ) + LazyColumn( + modifier = Modifier.padding(bottom = 8.dp) + ) { + items(datacenters) { dc -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .clickable { currentOnSelect(dc) } + .padding(vertical = 16.dp, horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = dc == currentValue, + onClick = null + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = dc.name, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = onDismiss) { + Text("ΠžΡ‚ΠΌΠ΅Π½Π°", style = MaterialTheme.typography.labelLarge) + } + } + } + } + } +} + +fun formatLogLine(raw: String): String { + // Raw logcat line example: + // 03-24 14:30:45.057 I/TgWsProxy(24567): INFO 11:30:45 WS pool warmup started... + // We want to extract: "11:30:45 WS pool warmup started..." + val infoIdx = raw.indexOf("INFO ") + if (infoIdx >= 0) { + return "β€’ " + raw.substring(infoIdx + 6).trim() + } + val warnIdx = raw.indexOf("WARN ") + if (warnIdx >= 0) { + return "⚠ " + raw.substring(warnIdx + 6).trim() + } + val errIdx = raw.indexOf("ERROR ") + if (errIdx >= 0) { + return "βœ– " + raw.substring(errIdx + 6).trim() + } + // Fallback: try to find the message after ): + val msgIdx = raw.indexOf("): ") + if (msgIdx >= 0) { + return "β€’ " + raw.substring(msgIdx + 3).trim() + } + return raw.trim() +} + +fun openTelegram(context: Context, url: String) { + val pm = context.packageManager + val uri = Uri.parse(url) + + for (pkg in telegramApps) { + try { + pm.getPackageInfo(pkg, 0) + val intent = Intent(Intent.ACTION_VIEW, uri) + intent.setPackage(pkg) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + return + } catch (e: PackageManager.NameNotFoundException) { + // App not found, skip + } catch (e: Exception) { + // Activity not found or other err + } + } + + // Fallback: just open any app that handles tg:// link + try { + val fallbackIntent = Intent(Intent.ACTION_VIEW, uri) + fallbackIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(fallbackIntent) + } catch (e: Exception) { + Toast.makeText(context, "Telegram Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½!", Toast.LENGTH_SHORT).show() + } +} + +object LogManager { + val logs = MutableStateFlow>(emptyList()) + private var job: Job? = null + + fun startListening() { + if (job?.isActive == true) return + job = CoroutineScope(Dispatchers.IO).launch { + try { + // Clear old logs just to avoid stale + Runtime.getRuntime().exec("logcat -c").waitFor() + val process = Runtime.getRuntime().exec(arrayOf("logcat", "-v", "time", "*:D")) + val reader = BufferedReader(InputStreamReader(process.inputStream)) + + val myPid = android.os.Process.myPid().toString() + while (isActive) { + val line = reader.readLine() ?: break + if (line.contains(myPid) && (line.contains("INFO") || line.contains("WARN") || line.contains("ERROR"))) { + logs.update { current -> + val n = current + line + if (n.size > 30) n.takeLast(30) else n + } + } + } + } catch (e: Exception) {} + } + } + + fun stopListening() { + job?.cancel() + job = null + logs.value = emptyList() + } +} diff --git a/app/src/main/java/com/example/tgwsproxy/NativeProxy.kt b/app/src/main/java/com/example/tgwsproxy/NativeProxy.kt new file mode 100644 index 0000000..2fc20f1 --- /dev/null +++ b/app/src/main/java/com/example/tgwsproxy/NativeProxy.kt @@ -0,0 +1,35 @@ +package com.example.tgwsproxy + +import com.sun.jna.Library +import com.sun.jna.Native +import com.sun.jna.Pointer + +interface ProxyLibrary : Library { + companion object { + val INSTANCE = Native.load("tgwsproxy", ProxyLibrary::class.java) as ProxyLibrary + } + + fun StartProxy(host: String, port: Int, dcIps: String, verbose: Int): Int + fun StopProxy(): Int + fun SetPoolSize(size: Int) + fun GetStats(): Pointer? + fun FreeString(p: Pointer) +} + +object NativeProxy { + fun startProxy(host: String, port: Int, dcIps: String, verbose: Int): Int { + return ProxyLibrary.INSTANCE.StartProxy(host, port, dcIps, verbose) + } + fun stopProxy(): Int { + return ProxyLibrary.INSTANCE.StopProxy() + } + fun setPoolSize(size: Int) { + ProxyLibrary.INSTANCE.SetPoolSize(size) + } + fun getStats(): String? { + val ptr = ProxyLibrary.INSTANCE.GetStats() ?: return null + val res = ptr.getString(0) + ProxyLibrary.INSTANCE.FreeString(ptr) + return res + } +} diff --git a/app/src/main/java/com/example/tgwsproxy/ProxyService.kt b/app/src/main/java/com/example/tgwsproxy/ProxyService.kt new file mode 100644 index 0000000..207c011 --- /dev/null +++ b/app/src/main/java/com/example/tgwsproxy/ProxyService.kt @@ -0,0 +1,196 @@ +package com.example.tgwsproxy + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import android.os.PowerManager +import androidx.core.app.NotificationCompat +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class ProxyService : Service() { + + private var wakeLock: PowerManager.WakeLock? = null + private var statsJob: Job? = null + + companion object { + const val ACTION_START = "com.example.tgwsproxy.START" + const val ACTION_STOP = "com.example.tgwsproxy.STOP" + const val EXTRA_PORT = "EXTRA_PORT" + const val EXTRA_IPS = "EXTRA_IPS" + const val EXTRA_POOL_SIZE = "EXTRA_POOL_SIZE" + + private const val NOTIFICATION_ID = 1 + private const val CHANNEL_ID = "ProxyServiceChannel" + + private val _isRunning = MutableStateFlow(false) + val isRunning: StateFlow = _isRunning + } + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_START -> { + val port = intent.getIntExtra(EXTRA_PORT, 8080) + val ips = intent.getStringExtra(EXTRA_IPS) ?: "" + val poolSize = intent.getIntExtra(EXTRA_POOL_SIZE, 4) + startProxy(port, ips, poolSize) + } + ACTION_STOP -> { + stopProxy() + } + } + return START_STICKY + } + + private fun startProxy(port: Int, ips: String, poolSize: Int = 4) { + if (_isRunning.value) return + + val notification = createNotification("Запуск прокси...") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE) + } else { + startForeground(NOTIFICATION_ID, notification) + } + + acquireWakeLock() + + Thread { + NativeProxy.setPoolSize(poolSize) + NativeProxy.startProxy("127.0.0.1", port, ips, 1) + }.start() + + _isRunning.value = true + + statsJob = CoroutineScope(Dispatchers.IO).launch { + while (isActive) { + delay(2000) + if (_isRunning.value) { + val rawStats = NativeProxy.getStats() ?: continue + val upRaw = extractStat(rawStats, "up=") + val downRaw = extractStat(rawStats, "down=") + + val totalBytes = parseHumanBytes(upRaw) + parseHumanBytes(downRaw) + val text = "Π’Ρ€Π°Ρ„ΠΈΠΊ: ${formatBytes(totalBytes)}" + val manager = getSystemService(NotificationManager::class.java) + manager?.notify(NOTIFICATION_ID, createNotification(text)) + } + } + } + } + + private fun extractStat(stats: String, key: String): String { + val idx = stats.indexOf(key) + if (idx == -1) return "0B" + val start = idx + key.length + val end = stats.indexOf(" ", start) + return if (end == -1) stats.substring(start) else stats.substring(start, end) + } + + private fun parseHumanBytes(s: String): Double { + val num = s.replace(Regex("[^0-9.]"), "").toDoubleOrNull() ?: 0.0 + return when { + s.endsWith("TB") -> num * 1024.0 * 1024 * 1024 * 1024 + s.endsWith("GB") -> num * 1024.0 * 1024 * 1024 + s.endsWith("MB") -> num * 1024.0 * 1024 + s.endsWith("KB") -> num * 1024.0 + else -> num + } + } + + private fun formatBytes(bytes: Double): String { + if (bytes < 1024) return "%.0fB".format(bytes) + if (bytes < 1024 * 1024) return "%.1fKB".format(bytes / 1024) + if (bytes < 1024 * 1024 * 1024) return "%.1fMB".format(bytes / (1024 * 1024)) + return "%.2fGB".format(bytes / (1024 * 1024 * 1024)) + } + + private fun stopProxy() { + statsJob?.cancel() + statsJob = null + Thread { NativeProxy.stopProxy() }.start() + releaseWakeLock() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + stopForeground(STOP_FOREGROUND_REMOVE) + } else { + stopForeground(true) + } + stopSelf() + _isRunning.value = false + } + + private fun acquireWakeLock() { + val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager + wakeLock = powerManager.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + "TgWsProxy::ServiceWakeLock" + ) + wakeLock?.acquire() + } + + private fun releaseWakeLock() { + try { + wakeLock?.let { + if (it.isHeld) { + it.release() + } + } + } catch (e: Exception) { + // Ignore wakelock exception + } + wakeLock = null + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val serviceChannel = NotificationChannel( + CHANNEL_ID, + "Π€ΠΎΠ½ΠΎΠ²Ρ‹ΠΉ ΠŸΡ€ΠΎΠΊΡΠΈ", + NotificationManager.IMPORTANCE_LOW + ) + val manager = getSystemService(NotificationManager::class.java) + manager?.createNotificationChannel(serviceChannel) + } + } + + private fun createNotification(content: String): Notification { + val stopIntent = Intent(this, ProxyService::class.java).apply { + action = ACTION_STOP + } + val stopPendingIntent = PendingIntent.getService( + this, 0, stopIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("Telegram WS Proxy") + .setContentText(content) + .setSmallIcon(R.drawable.ic_notification) // Local pure vector for Android 16 compatibility + .addAction(android.R.drawable.ic_menu_close_clear_cancel, "ΠžΡ‚ΠΊΠ»ΡŽΡ‡ΠΈΡ‚ΡŒ", stopPendingIntent) + .setOngoing(true) + .setOnlyAlertOnce(true) // prevent vibrate/sound on updates + .build() + } + + override fun onDestroy() { + if (_isRunning.value) { + stopProxy() + } + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? { + return null + } +} diff --git a/app/src/main/res/drawable/app_bg.webp b/app/src/main/res/drawable/app_bg.webp new file mode 100644 index 0000000..cfe301d Binary files /dev/null and b/app/src/main/res/drawable/app_bg.webp differ diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 0000000..034a443 --- /dev/null +++ b/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_stat_connected.xml b/app/src/main/res/drawable/ic_stat_connected.xml new file mode 100644 index 0000000..1024271 --- /dev/null +++ b/app/src/main/res/drawable/ic_stat_connected.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_stop.xml b/app/src/main/res/drawable/ic_stop.xml new file mode 100644 index 0000000..172430c --- /dev/null +++ b/app/src/main/res/drawable/ic_stop.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_transparent_fg.xml b/app/src/main/res/drawable/ic_transparent_fg.xml new file mode 100644 index 0000000..64ab7e9 --- /dev/null +++ b/app/src/main/res/drawable/ic_transparent_fg.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..32e6441 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..32e6441 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..882e4d6 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_background_img.png b/app/src/main/res/mipmap-hdpi/ic_launcher_background_img.png new file mode 100644 index 0000000..ba0ec42 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_background_img.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..ecf7133 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..849bfb4 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_background_img.png b/app/src/main/res/mipmap-mdpi/ic_launcher_background_img.png new file mode 100644 index 0000000..8a19f53 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_background_img.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..e023165 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..c969fdf Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_background_img.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_background_img.png new file mode 100644 index 0000000..6ebec7e Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_background_img.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..7d4079f Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..537741a Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_background_img.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_background_img.png new file mode 100644 index 0000000..3e80193 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_background_img.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..4cf6f68 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..1ebf6ff Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background_img.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background_img.png new file mode 100644 index 0000000..a3aa47f Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background_img.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..bb266d5 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/play_store_512.png b/app/src/main/res/play_store_512.png new file mode 100644 index 0000000..79f01f8 Binary files /dev/null and b/app/src/main/res/play_store_512.png differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..927922e --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #1E1E1E + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..69e61e7 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..b59bc38 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,5 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + id("com.android.application") version "8.2.2" apply false + id("org.jetbrains.kotlin.android") version "1.9.22" apply false +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..763992b --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module tg-ws-proxy + +go 1.25 + +require ( + golang.org/x/crypto v0.31.0 +) \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..2e11322 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonTransitiveRClass=true diff --git a/icon.ico b/icon.ico deleted file mode 100644 index 86c4b19..0000000 Binary files a/icon.ico and /dev/null differ diff --git a/linux.py b/linux.py deleted file mode 100644 index 664c948..0000000 --- a/linux.py +++ /dev/null @@ -1,871 +0,0 @@ -from __future__ import annotations - -import asyncio as _asyncio -import json -import logging -import logging.handlers -import os -import subprocess -import sys -import threading -import time -from pathlib import Path -from typing import Dict, Optional - -import customtkinter as ctk -import psutil -import pyperclip -import pystray -from PIL import Image, ImageDraw, ImageFont - -import proxy.tg_ws_proxy as tg_ws_proxy - -APP_NAME = "TgWsProxy" -APP_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / 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, - "log_max_mb": 5, - "buf_kb": 256, - "pool_size": 4, -} - - -_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") - - -def _same_process(lock_meta: dict, proc: psutil.Process) -> bool: - try: - lock_ct = float(lock_meta.get("create_time", 0.0)) - proc_ct = float(proc.create_time()) - if lock_ct > 0 and abs(lock_ct - proc_ct) > 1.0: - return False - except Exception: - return False - - try: - cmdline = proc.cmdline() - for arg in cmdline: - if "linux.py" in arg: - return True - except Exception: - pass - - frozen = bool(getattr(sys, "frozen", False)) - if frozen: - return APP_NAME.lower() in proc.name().lower() - - return False - - -def _release_lock(): - global _lock_file_path - if not _lock_file_path: - return - try: - _lock_file_path.unlink(missing_ok=True) - except Exception: - pass - _lock_file_path = None - - -def _acquire_lock() -> bool: - global _lock_file_path - _ensure_dirs() - lock_files = list(APP_DIR.glob("*.lock")) - - for f in lock_files: - pid = None - meta: dict = {} - - try: - pid = int(f.stem) - except Exception: - f.unlink(missing_ok=True) - continue - - try: - raw = f.read_text(encoding="utf-8").strip() - if raw: - meta = json.loads(raw) - except Exception: - meta = {} - - try: - proc = psutil.Process(pid) - if _same_process(meta, proc): - return False - except Exception: - pass - - f.unlink(missing_ok=True) - - lock_file = APP_DIR / f"{os.getpid()}.lock" - try: - proc = psutil.Process(os.getpid()) - payload = { - "create_time": proc.create_time(), - } - lock_file.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") - except Exception: - lock_file.touch() - - _lock_file_path = lock_file - return True - - -def _ensure_dirs(): - APP_DIR.mkdir(parents=True, exist_ok=True) - - -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) - - -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) - - -def setup_logging(verbose: bool = False, log_max_mb: float = 5): - _ensure_dirs() - root = logging.getLogger() - root.setLevel(logging.DEBUG if verbose else logging.INFO) - - fh = logging.handlers.RotatingFileHandler( - str(LOG_FILE), - maxBytes=max(32 * 1024, log_max_mb * 1024 * 1024), - backupCount=0, - 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 _make_icon_image(size: int = 64): - if Image is None: - raise RuntimeError("Pillow is required for tray icon") - img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) - draw = ImageDraw.Draw(img) - - margin = 2 - draw.ellipse( - [margin, margin, size - margin, size - margin], fill=(0, 136, 204, 255) - ) - - try: - font = ImageFont.truetype( - "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", - size=int(size * 0.55), - ) - except Exception: - try: - font = ImageFont.truetype( - "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", 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] - tx = (size - tw) // 2 - bbox[0] - ty = (size - th) // 2 - bbox[1] - draw.text((tx, ty), "T", fill=(255, 255, 255, 255), font=font) - - return img - - -def _load_icon(): - icon_path = Path(__file__).parent / "icon.ico" - if icon_path.exists() and Image: - try: - return Image.open(str(icon_path)) - except Exception: - 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 "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) - - buf_kb = cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"]) - pool_size = cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]) - tg_ws_proxy._RECV_BUF = max(4, buf_kb) * 1024 - tg_ws_proxy._SEND_BUF = tg_ws_proxy._RECV_BUF - tg_ws_proxy._WS_POOL_SIZE = max(0, pool_size) - - _proxy_thread = threading.Thread( - target=_run_proxy_thread, - args=(port, dc_opt, verbose, host), - daemon=True, - name="proxy", - ) - _proxy_thread.start() - - -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") - - -def restart_proxy(): - log.info("Restarting proxy...") - stop_proxy() - time.sleep(0.3) - start_proxy() - - -def _show_error(text: str, title: str = "TG WS Proxy β€” Ошибка"): - import tkinter as _tk - from tkinter import messagebox as _mb - - root = _tk.Tk() - root.withdraw() - _mb.showerror(title, text, parent=root) - root.destroy() - - -def _show_info(text: str, title: str = "TG WS Proxy"): - import tkinter as _tk - from tkinter import messagebox as _mb - - root = _tk.Tk() - root.withdraw() - _mb.showinfo(title, text, parent=root) - root.destroy() - - -def _on_open_in_telegram(icon=None, item=None): - port = _config.get("port", DEFAULT_CONFIG["port"]) - url = f"tg://socks?server=127.0.0.1&port={port}" - log.info("Copying %s", url) - - try: - pyperclip.copy(url) - _show_info( - f"Бсылка скопирована Π² Π±ΡƒΡ„Π΅Ρ€ ΠΎΠ±ΠΌΠ΅Π½Π°, ΠΎΡ‚ΠΏΡ€Π°Π²ΡŒΡ‚Π΅ Π΅Ρ‘ Π² Telegram ΠΈ Π½Π°ΠΆΠΌΠΈΡ‚Π΅ ΠΏΠΎ Π½Π΅ΠΉ Π›ΠšΠœ:\n{url}", - "TG WS Proxy", - ) - except Exception as exc: - log.error("Clipboard copy failed: %s", exc) - _show_error(f"НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ ΡΠΊΠΎΠΏΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ ссылку:\n{exc}") - - -def _on_restart(icon=None, item=None): - threading.Thread(target=restart_proxy, daemon=True).start() - - -def _on_edit_config(icon=None, item=None): - threading.Thread(target=_edit_config_dialog, daemon=True).start() - - -def _edit_config_dialog(): - if ctk is None: - _show_error("customtkinter Π½Π΅ установлСн.") - return - - cfg = dict(_config) - - ctk.set_appearance_mode("light") - ctk.set_default_color_theme("blue") - - root = ctk.CTk() - root.title("TG WS Proxy β€” Настройки") - root.resizable(False, False) - root.attributes("-topmost", True) - - icon_img = _load_icon() - if icon_img: - from PIL import ImageTk - - _photo = ImageTk.PhotoImage(icon_img.resize((64, 64))) - root.iconphoto(False, _photo) - - TG_BLUE = "#3390ec" - TG_BLUE_HOVER = "#2b7cd4" - BG = "#ffffff" - FIELD_BG = "#f0f2f5" - FIELD_BORDER = "#d6d9dc" - TEXT_PRIMARY = "#000000" - TEXT_SECONDARY = "#707579" - FONT_FAMILY = "Sans" - - w, h = 420, 540 - sw = root.winfo_screenwidth() - sh = root.winfo_screenheight() - root.geometry(f"{w}x{h}+{(sw - w) // 2}+{(sh - h) // 2}") - root.configure(fg_color=BG) - - frame = ctk.CTkFrame(root, fg_color=BG, corner_radius=0) - frame.pack(fill="both", expand=True, padx=24, pady=20) - - # Host - ctk.CTkLabel( - frame, - text="IP-адрСс прокси", - font=(FONT_FAMILY, 13), - text_color=TEXT_PRIMARY, - anchor="w", - ).pack(anchor="w", pady=(0, 4)) - host_var = ctk.StringVar(value=cfg.get("host", "127.0.0.1")) - host_entry = ctk.CTkEntry( - frame, - textvariable=host_var, - width=200, - height=36, - font=(FONT_FAMILY, 13), - corner_radius=10, - fg_color=FIELD_BG, - border_color=FIELD_BORDER, - border_width=1, - text_color=TEXT_PRIMARY, - ) - host_entry.pack(anchor="w", pady=(0, 12)) - - # Port - ctk.CTkLabel( - frame, - text="ΠŸΠΎΡ€Ρ‚ прокси", - font=(FONT_FAMILY, 13), - text_color=TEXT_PRIMARY, - anchor="w", - ).pack(anchor="w", pady=(0, 4)) - port_var = ctk.StringVar(value=str(cfg.get("port", 1080))) - port_entry = ctk.CTkEntry( - frame, - textvariable=port_var, - width=120, - height=36, - font=(FONT_FAMILY, 13), - corner_radius=10, - fg_color=FIELD_BG, - border_color=FIELD_BORDER, - border_width=1, - text_color=TEXT_PRIMARY, - ) - port_entry.pack(anchor="w", pady=(0, 12)) - - # DC-IP mappings - ctk.CTkLabel( - frame, - text="DC β†’ IP ΠΌΠ°ΠΏΠΏΠΈΠ½Π³ΠΈ (ΠΏΠΎ ΠΎΠ΄Π½ΠΎΠΌΡƒ Π½Π° строку, Ρ„ΠΎΡ€ΠΌΠ°Ρ‚ DC:IP)", - font=(FONT_FAMILY, 13), - text_color=TEXT_PRIMARY, - anchor="w", - ).pack(anchor="w", pady=(0, 4)) - dc_textbox = ctk.CTkTextbox( - frame, - width=370, - height=120, - font=("Monospace", 12), - corner_radius=10, - fg_color=FIELD_BG, - border_color=FIELD_BORDER, - border_width=1, - text_color=TEXT_PRIMARY, - ) - dc_textbox.pack(anchor="w", pady=(0, 12)) - dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"]))) - - # Verbose - verbose_var = ctk.BooleanVar(value=cfg.get("verbose", False)) - ctk.CTkCheckBox( - frame, - text="ΠŸΠΎΠ΄Ρ€ΠΎΠ±Π½ΠΎΠ΅ Π»ΠΎΠ³ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ (verbose)", - variable=verbose_var, - font=(FONT_FAMILY, 13), - text_color=TEXT_PRIMARY, - fg_color=TG_BLUE, - hover_color=TG_BLUE_HOVER, - corner_radius=6, - border_width=2, - border_color=FIELD_BORDER, - ).pack(anchor="w", pady=(0, 8)) - - # Advanced: buf_kb, pool_size, log_max_mb - adv_frame = ctk.CTkFrame(frame, fg_color="transparent") - adv_frame.pack(anchor="w", fill="x", pady=(4, 8)) - - for col, (lbl, key, w_) in enumerate([ - ("Π‘ΡƒΡ„Π΅Ρ€ (KB, 256 default)", "buf_kb", 120), - ("WS ΠΏΡƒΠ»ΠΎΠ² (4 default)", "pool_size", 120), - ("Log size (MB, 5 def)", "log_max_mb", 120), - ]): - col_frame = ctk.CTkFrame(adv_frame, fg_color="transparent") - col_frame.pack(side="left", padx=(0, 10)) - ctk.CTkLabel(col_frame, text=lbl, font=(FONT_FAMILY, 11), - text_color=TEXT_SECONDARY, anchor="w").pack(anchor="w") - ctk.CTkEntry(col_frame, width=w_, height=30, font=(FONT_FAMILY, 12), - corner_radius=8, fg_color=FIELD_BG, - border_color=FIELD_BORDER, border_width=1, - text_color=TEXT_PRIMARY, - textvariable=ctk.StringVar( - value=str(cfg.get(key, DEFAULT_CONFIG[key])) - )).pack(anchor="w") - - _adv_entries = list(adv_frame.winfo_children()) - _adv_keys = ["buf_kb", "pool_size", "log_max_mb"] - - def on_save(): - import socket as _sock - - host_val = host_var.get().strip() - try: - _sock.inet_aton(host_val) - except OSError: - _show_error("НСкоррСктный IP-адрСс.") - return - - try: - port_val = int(port_var.get().strip()) - if not (1 <= port_val <= 65535): - raise ValueError - except ValueError: - _show_error("ΠŸΠΎΡ€Ρ‚ Π΄ΠΎΠ»ΠΆΠ΅Π½ Π±Ρ‹Ρ‚ΡŒ числом 1-65535") - return - - lines = [ - l.strip() - for l in dc_textbox.get("1.0", "end").strip().splitlines() - if l.strip() - ] - try: - tg_ws_proxy.parse_dc_ip_list(lines) - except ValueError as e: - _show_error(str(e)) - return - - new_cfg = { - "host": host_val, - "port": port_val, - "dc_ip": lines, - "verbose": verbose_var.get(), - } - - for i, key in enumerate(_adv_keys): - col_frame = _adv_entries[i] - entry = col_frame.winfo_children()[1] - try: - val = float(entry.get().strip()) - if key in ("buf_kb", "pool_size"): - val = int(val) - new_cfg[key] = val - except ValueError: - new_cfg[key] = DEFAULT_CONFIG[key] - save_config(new_cfg) - _config.update(new_cfg) - log.info("Config saved: %s", new_cfg) - - _tray_icon.menu = _build_menu() - - from tkinter import messagebox - - if messagebox.askyesno( - "ΠŸΠ΅Ρ€Π΅Π·Π°ΠΏΡƒΡΡ‚ΠΈΡ‚ΡŒ?", - "Настройки сохранСны.\n\nΠŸΠ΅Ρ€Π΅Π·Π°ΠΏΡƒΡΡ‚ΠΈΡ‚ΡŒ прокси сСйчас?", - parent=root, - ): - root.destroy() - restart_proxy() - else: - root.destroy() - - def on_cancel(): - root.destroy() - - btn_frame = ctk.CTkFrame(frame, fg_color="transparent") - btn_frame.pack(fill="x", pady=(20, 0)) - ctk.CTkButton(btn_frame, text="Π‘ΠΎΡ…Ρ€Π°Π½ΠΈΡ‚ΡŒ", height=38, - font=(FONT_FAMILY, 14, "bold"), corner_radius=10, - fg_color=TG_BLUE, hover_color=TG_BLUE_HOVER, - text_color="#ffffff", - command=on_save).pack(side="left", fill="x", expand=True, padx=(0, 8)) - ctk.CTkButton(btn_frame, text="ΠžΡ‚ΠΌΠ΅Π½Π°", height=38, - font=(FONT_FAMILY, 14), corner_radius=10, - fg_color=FIELD_BG, hover_color=FIELD_BORDER, - text_color=TEXT_PRIMARY, border_width=1, - border_color=FIELD_BORDER, - command=on_cancel).pack(side="right", fill="x", expand=True) - - root.mainloop() - - -def _on_open_logs(icon=None, item=None): - log.info("Opening log file: %s", LOG_FILE) - if LOG_FILE.exists(): - env = os.environ.copy() - env.pop("VIRTUAL_ENV", None) - env.pop("PYTHONPATH", None) - env.pop("PYTHONHOME", None) - - subprocess.Popen( - ["xdg-open", str(LOG_FILE)], - env=env, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - stdin=subprocess.DEVNULL, - start_new_session=True, - ) - else: - _show_info("Π€Π°ΠΉΠ» Π»ΠΎΠ³ΠΎΠ² Π΅Ρ‰Ρ‘ Π½Π΅ создан.", "TG WS Proxy") - - -def _on_exit(icon=None, item=None): - global _exiting - if _exiting: - os._exit(0) - return - _exiting = True - log.info("User requested exit") - - def _force_exit(): - time.sleep(3) - os._exit(0) - - threading.Thread(target=_force_exit, daemon=True, name="force-exit").start() - - if icon: - icon.stop() - - -def _show_first_run(): - _ensure_dirs() - if FIRST_RUN_MARKER.exists(): - return - - host = _config.get("host", DEFAULT_CONFIG["host"]) - port = _config.get("port", DEFAULT_CONFIG["port"]) - tg_url = f"tg://socks?server={host}&port={port}" - - if ctk is None: - FIRST_RUN_MARKER.touch() - return - - ctk.set_appearance_mode("light") - ctk.set_default_color_theme("blue") - - TG_BLUE = "#3390ec" - TG_BLUE_HOVER = "#2b7cd4" - BG = "#ffffff" - FIELD_BG = "#f0f2f5" - FIELD_BORDER = "#d6d9dc" - TEXT_PRIMARY = "#000000" - TEXT_SECONDARY = "#707579" - FONT_FAMILY = "Sans" - - root = ctk.CTk() - root.title("TG WS Proxy") - root.resizable(False, False) - root.attributes("-topmost", True) - - icon_img = _load_icon() - if icon_img: - from PIL import ImageTk - - _photo = ImageTk.PhotoImage(icon_img.resize((64, 64))) - root.iconphoto(False, _photo) - - w, h = 520, 440 - sw = root.winfo_screenwidth() - sh = root.winfo_screenheight() - root.geometry(f"{w}x{h}+{(sw - w) // 2}+{(sh - h) // 2}") - root.configure(fg_color=BG) - - frame = ctk.CTkFrame(root, fg_color=BG, corner_radius=0) - frame.pack(fill="both", expand=True, padx=28, pady=24) - - title_frame = ctk.CTkFrame(frame, fg_color="transparent") - title_frame.pack(anchor="w", pady=(0, 16), fill="x") - - # Blue accent bar - accent_bar = ctk.CTkFrame( - title_frame, fg_color=TG_BLUE, width=4, height=32, corner_radius=2 - ) - accent_bar.pack(side="left", padx=(0, 12)) - - ctk.CTkLabel( - title_frame, - text="ΠŸΡ€ΠΎΠΊΡΠΈ Π·Π°ΠΏΡƒΡ‰Π΅Π½ ΠΈ Ρ€Π°Π±ΠΎΡ‚Π°Π΅Ρ‚ Π² систСмном Ρ‚Ρ€Π΅Π΅", - font=(FONT_FAMILY, 17, "bold"), - text_color=TEXT_PRIMARY, - ).pack(side="left") - - # Info sections - sections = [ - ("Как ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡ΠΈΡ‚ΡŒ Telegram Desktop:", True), - (" АвтоматичСски:", True), - (f" ПКМ ΠΏΠΎ ΠΈΠΊΠΎΠ½ΠΊΠ΅ Π² Ρ‚Ρ€Π΅Π΅ β†’ Β«ΠžΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ Π² TelegramΒ»", False), - (f" Или ссылка: {tg_url}", False), - ("\n Π’Ρ€ΡƒΡ‡Π½ΡƒΡŽ:", True), - (" Настройки β†’ ΠŸΡ€ΠΎΠ΄Π²ΠΈΠ½ΡƒΡ‚Ρ‹Π΅ β†’ Π’ΠΈΠΏ ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΡ β†’ ΠŸΡ€ΠΎΠΊΡΠΈ", False), - (f" SOCKS5 β†’ {host} : {port} (Π±Π΅Π· Π»ΠΎΠ³ΠΈΠ½Π°/пароля)", False), - ] - - for text, bold in sections: - weight = "bold" if bold else "normal" - ctk.CTkLabel( - frame, - text=text, - font=(FONT_FAMILY, 13, weight), - text_color=TEXT_PRIMARY, - anchor="w", - justify="left", - ).pack(anchor="w", pady=1) - - # Spacer - ctk.CTkFrame(frame, fg_color="transparent", height=16).pack() - - # Separator - ctk.CTkFrame(frame, fg_color=FIELD_BORDER, height=1, corner_radius=0).pack( - fill="x", pady=(0, 12) - ) - - # Checkbox - auto_var = ctk.BooleanVar(value=True) - ctk.CTkCheckBox( - frame, - text="ΠžΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ прокси Π² Telegram сСйчас", - variable=auto_var, - font=(FONT_FAMILY, 13), - text_color=TEXT_PRIMARY, - fg_color=TG_BLUE, - hover_color=TG_BLUE_HOVER, - corner_radius=6, - border_width=2, - border_color=FIELD_BORDER, - ).pack(anchor="w", pady=(0, 16)) - - def on_ok(): - FIRST_RUN_MARKER.touch() - open_tg = auto_var.get() - root.destroy() - if open_tg: - _on_open_in_telegram() - - ctk.CTkButton( - frame, - text="ΠΠ°Ρ‡Π°Ρ‚ΡŒ", - width=180, - height=42, - font=(FONT_FAMILY, 15, "bold"), - corner_radius=10, - fg_color=TG_BLUE, - hover_color=TG_BLUE_HOVER, - text_color="#ffffff", - command=on_ok, - ).pack(pady=(0, 0)) - - root.protocol("WM_DELETE_WINDOW", on_ok) - root.mainloop() - - -def _has_ipv6_enabled() -> bool: - import socket as _sock - - try: - addrs = _sock.getaddrinfo(_sock.gethostname(), None, _sock.AF_INET6) - for addr in addrs: - ip = addr[4][0] - if ip and not ip.startswith("::1") and not ip.startswith("fe80::1"): - return True - except Exception: - pass - try: - s = _sock.socket(_sock.AF_INET6, _sock.SOCK_STREAM) - s.bind(("::1", 0)) - s.close() - return True - except Exception: - return False - - -def _check_ipv6_warning(): - _ensure_dirs() - if IPV6_WARN_MARKER.exists(): - return - if not _has_ipv6_enabled(): - return - - IPV6_WARN_MARKER.touch() - - threading.Thread(target=_show_ipv6_dialog, daemon=True).start() - - -def _show_ipv6_dialog(): - _show_info( - "На вашСм ΠΊΠΎΠΌΠΏΡŒΡŽΡ‚Π΅Ρ€Π΅ Π²ΠΊΠ»ΡŽΡ‡Π΅Π½Π° ΠΏΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΊΠ° ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΡ ΠΏΠΎ IPv6.\n\n" - "Telegram ΠΌΠΎΠΆΠ΅Ρ‚ ΠΏΡ‹Ρ‚Π°Ρ‚ΡŒΡΡ ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π°Ρ‚ΡŒΡΡ Ρ‡Π΅Ρ€Π΅Π· IPv6, " - "Ρ‡Ρ‚ΠΎ Π½Π΅ поддСрТиваСтся ΠΈ ΠΌΠΎΠΆΠ΅Ρ‚ привСсти ΠΊ ошибкам.\n\n" - "Если прокси Π½Π΅ Ρ€Π°Π±ΠΎΡ‚Π°Π΅Ρ‚ ΠΈΠ»ΠΈ Π² Π»ΠΎΠ³Π°Ρ… ΠΏΡ€ΠΈΡΡƒΡ‚ΡΡ‚Π²ΡƒΡŽΡ‚ ошибки, " - "связанныС с ΠΏΠΎΠΏΡ‹Ρ‚ΠΊΠ°ΠΌΠΈ ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΡ ΠΏΠΎ IPv6 - " - "ΠΏΠΎΠΏΡ€ΠΎΠ±ΡƒΠΉΡ‚Π΅ ΠΎΡ‚ΠΊΠ»ΡŽΡ‡ΠΈΡ‚ΡŒ Π² настройках прокси Telegram ΠΏΠΎΠΏΡ‹Ρ‚ΠΊΡƒ соСдинСния " - "ΠΏΠΎ IPv6. Если данная ΠΌΠ΅Ρ€Π° Π½Π΅ ΠΏΠΎΠΌΠΎΠ³Π°Π΅Ρ‚, ΠΏΠΎΠΏΡ€ΠΎΠ±ΡƒΠΉΡ‚Π΅ ΠΎΡ‚ΠΊΠ»ΡŽΡ‡ΠΈΡ‚ΡŒ IPv6 " - "Π² систСмС.\n\n" - "Π­Ρ‚ΠΎ ΠΏΡ€Π΅Π΄ΡƒΠΏΡ€Π΅ΠΆΠ΄Π΅Π½ΠΈΠ΅ Π±ΡƒΠ΄Π΅Ρ‚ ΠΏΠΎΠΊΠ°Π·Π°Π½ΠΎ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ ΠΎΠ΄ΠΈΠ½ Ρ€Π°Π·.", - "TG WS Proxy", - ) - - -def _build_menu(): - if pystray is None: - return None - host = _config.get("host", DEFAULT_CONFIG["host"]) - port = _config.get("port", DEFAULT_CONFIG["port"]) - return pystray.Menu( - pystray.MenuItem( - f"ΠžΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ Π² Telegram ({host}:{port})", _on_open_in_telegram, default=True - ), - pystray.Menu.SEPARATOR, - pystray.MenuItem("ΠŸΠ΅Ρ€Π΅Π·Π°ΠΏΡƒΡΡ‚ΠΈΡ‚ΡŒ прокси", _on_restart), - pystray.MenuItem("Настройки...", _on_edit_config), - pystray.MenuItem("ΠžΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ Π»ΠΎΠ³ΠΈ", _on_open_logs), - pystray.Menu.SEPARATOR, - pystray.MenuItem("Π’Ρ‹Ρ…ΠΎΠ΄", _on_exit), - ) - - -def run_tray(): - global _tray_icon, _config - - _config = load_config() - save_config(_config) - - if LOG_FILE.exists(): - try: - LOG_FILE.unlink() - except Exception: - pass - - setup_logging(_config.get("verbose", False), - log_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])) - log.info("TG WS Proxy tray app starting") - log.info("Config: %s", _config) - log.info("Log file: %s", LOG_FILE) - - if pystray is None or Image is None: - log.error("pystray or Pillow not installed; running in console mode") - start_proxy() - try: - while True: - time.sleep(1) - except KeyboardInterrupt: - stop_proxy() - return - - start_proxy() - - _show_first_run() - _check_ipv6_warning() - - icon_image = _load_icon() - _tray_icon = pystray.Icon(APP_NAME, icon_image, "TG WS Proxy", menu=_build_menu()) - - log.info("Tray icon running") - _tray_icon.run() - - stop_proxy() - log.info("Tray app exited") - - -def main(): - if not _acquire_lock(): - _show_info("ΠŸΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ ΡƒΠΆΠ΅ Π·Π°ΠΏΡƒΡ‰Π΅Π½ΠΎ.", os.path.basename(sys.argv[0])) - return - - try: - run_tray() - finally: - _release_lock() - - -if __name__ == "__main__": - main() diff --git a/macos.py b/macos.py deleted file mode 100644 index 46eb5cf..0000000 --- a/macos.py +++ /dev/null @@ -1,691 +0,0 @@ -from __future__ import annotations - -import json -import logging -import logging.handlers -import os -import psutil -import subprocess -import sys -import threading -import time -import webbrowser -import asyncio as _asyncio -from pathlib import Path -from typing import Dict, Optional - -try: - import rumps -except ImportError: - rumps = None - -try: - from PIL import Image, ImageDraw, ImageFont -except ImportError: - Image = ImageDraw = ImageFont = None - -try: - import pyperclip -except ImportError: - pyperclip = None - -import proxy.tg_ws_proxy as tg_ws_proxy - -APP_NAME = "TgWsProxy" -APP_DIR = Path.home() / "Library" / "Application Support" / 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" -MENUBAR_ICON_PATH = APP_DIR / "menubar_icon.png" - -DEFAULT_CONFIG = { - "port": 1080, - "host": "127.0.0.1", - "dc_ip": ["2:149.154.167.220", "4:149.154.167.220"], - "verbose": False, - "log_max_mb": 5, - "buf_kb": 256, - "pool_size": 4, -} - -_proxy_thread: Optional[threading.Thread] = None -_async_stop: Optional[object] = None -_app: Optional[object] = None -_config: dict = {} -_exiting: bool = False -_lock_file_path: Optional[Path] = None - -log = logging.getLogger("tg-ws-tray") - - -# Single-instance lock - -def _same_process(lock_meta: dict, proc: psutil.Process) -> bool: - try: - lock_ct = float(lock_meta.get("create_time", 0.0)) - proc_ct = float(proc.create_time()) - if lock_ct > 0 and abs(lock_ct - proc_ct) > 1.0: - return False - except Exception: - return False - - frozen = bool(getattr(sys, "frozen", False)) - if frozen: - return APP_NAME.lower() in proc.name().lower() - return False - - -def _release_lock(): - global _lock_file_path - if not _lock_file_path: - return - try: - _lock_file_path.unlink(missing_ok=True) - except Exception: - pass - _lock_file_path = None - - -def _acquire_lock() -> bool: - global _lock_file_path - _ensure_dirs() - lock_files = list(APP_DIR.glob("*.lock")) - - for f in lock_files: - pid = None - meta: dict = {} - - try: - pid = int(f.stem) - except Exception: - f.unlink(missing_ok=True) - continue - - try: - raw = f.read_text(encoding="utf-8").strip() - if raw: - meta = json.loads(raw) - except Exception: - meta = {} - - try: - proc = psutil.Process(pid) - if _same_process(meta, proc): - return False - except Exception: - pass - - f.unlink(missing_ok=True) - - lock_file = APP_DIR / f"{os.getpid()}.lock" - try: - proc = psutil.Process(os.getpid()) - payload = {"create_time": proc.create_time()} - lock_file.write_text(json.dumps(payload, ensure_ascii=False), - encoding="utf-8") - except Exception: - lock_file.touch() - - _lock_file_path = lock_file - return True - - -# Filesystem helpers - -def _ensure_dirs(): - APP_DIR.mkdir(parents=True, exist_ok=True) - - -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) - - -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) - - -def setup_logging(verbose: bool = False, log_max_mb: float = 5): - _ensure_dirs() - root = logging.getLogger() - root.setLevel(logging.DEBUG if verbose else logging.INFO) - - fh = logging.handlers.RotatingFileHandler( - str(LOG_FILE), - maxBytes=max(32 * 1024, log_max_mb * 1024 * 1024), - backupCount=0, - 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) - - -# Menubar icon - -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] - tx = (size - tw) // 2 - bbox[0] - ty = (size - th) // 2 - bbox[1] - draw.text((tx, ty), "T", fill=(255, 255, 255, 255), font=font) - return img - -# Generate menubar icon PNG if it does not exist. -def _ensure_menubar_icon(): - if MENUBAR_ICON_PATH.exists(): - return - _ensure_dirs() - img = _make_menubar_icon(44) - if img: - img.save(str(MENUBAR_ICON_PATH), "PNG") - - -# Native macOS dialogs - -def _escape_osascript_text(text: str) -> str: - return text.replace('\\', '\\\\').replace('"', '\\"') - - -def _osascript(script: str) -> str: - r = subprocess.run( - ['osascript', '-e', script], - capture_output=True, text=True) - return r.stdout.strip() - - -def _show_error(text: str, title: str = "TG WS Proxy"): - text_esc = _escape_osascript_text(text) - title_esc = _escape_osascript_text(title) - _osascript( - f'display dialog "{text_esc}" with title "{title_esc}" ' - f'buttons {{"OK"}} default button "OK" with icon stop') - - -def _show_info(text: str, title: str = "TG WS Proxy"): - text_esc = _escape_osascript_text(text) - title_esc = _escape_osascript_text(title) - _osascript( - f'display dialog "{text_esc}" with title "{title_esc}" ' - f'buttons {{"OK"}} default button "OK" with icon note') - - -def _ask_yes_no(text: str, title: str = "TG WS Proxy") -> bool: - result = _ask_yes_no_close(text, title) - return result is True - - -def _ask_yes_no_close(text: str, - title: str = "TG WS Proxy") -> Optional[bool]: - text_esc = _escape_osascript_text(text) - title_esc = _escape_osascript_text(title) - r = subprocess.run( - ['osascript', '-e', - f'button returned of (display dialog "{text_esc}" ' - f'with title "{title_esc}" ' - f'buttons {{"Π—Π°ΠΊΡ€Ρ‹Ρ‚ΡŒ", "НСт", "Π”Π°"}} ' - f'default button "Π”Π°" cancel button "Π—Π°ΠΊΡ€Ρ‹Ρ‚ΡŒ" with icon note)'], - capture_output=True, text=True) - if r.returncode != 0: - return None - - result = r.stdout.strip() - if result == "Π”Π°": - return True - if result == "НСт": - return False - return None - - -# Proxy lifecycle - -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 "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) - - buf_kb = cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"]) - pool_size = cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]) - tg_ws_proxy._RECV_BUF = max(4, buf_kb) * 1024 - tg_ws_proxy._SEND_BUF = tg_ws_proxy._RECV_BUF - tg_ws_proxy._WS_POOL_SIZE = max(0, pool_size) - - _proxy_thread = threading.Thread( - target=_run_proxy_thread, - args=(port, dc_opt, verbose, host), - daemon=True, name="proxy") - _proxy_thread.start() - - -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") - - -def restart_proxy(): - log.info("Restarting proxy...") - stop_proxy() - time.sleep(0.3) - start_proxy() - - -# Menu callbacks - -def _on_open_in_telegram(_=None): - port = _config.get("port", DEFAULT_CONFIG["port"]) - url = f"tg://socks?server=127.0.0.1&port={port}" - log.info("Opening %s", url) - try: - result = subprocess.call(['open', url]) - if result != 0: - raise RuntimeError("open command failed") - except Exception: - log.info("open command failed, trying webbrowser") - try: - if not webbrowser.open(url): - raise RuntimeError("webbrowser.open returned False") - except Exception: - log.info("Browser open failed, copying to clipboard") - try: - if pyperclip: - pyperclip.copy(url) - else: - subprocess.run(['pbcopy'], input=url.encode(), - check=True) - _show_info( - "НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ ΠΎΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ Telegram автоматичСски.\n\n" - f"Бсылка скопирована Π² Π±ΡƒΡ„Π΅Ρ€ ΠΎΠ±ΠΌΠ΅Π½Π°:\n{url}") - except Exception as exc: - log.error("Clipboard copy failed: %s", exc) - _show_error(f"НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ ΡΠΊΠΎΠΏΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ ссылку:\n{exc}") - - -def _on_restart(_=None): - def _do_restart(): - global _config - _config = load_config() - if _app: - _app.update_menu_title() - restart_proxy() - - threading.Thread(target=_do_restart, daemon=True).start() - - -def _on_open_logs(_=None): - log.info("Opening log file: %s", LOG_FILE) - if LOG_FILE.exists(): - subprocess.call(['open', str(LOG_FILE)]) - else: - _show_info("Π€Π°ΠΉΠ» Π»ΠΎΠ³ΠΎΠ² Π΅Ρ‰Ρ‘ Π½Π΅ создан.") - -# Show a native text input dialog. Returns None if cancelled. -def _osascript_input(prompt: str, default: str, - title: str = "TG WS Proxy") -> Optional[str]: - prompt_esc = _escape_osascript_text(prompt) - default_esc = _escape_osascript_text(default) - title_esc = _escape_osascript_text(title) - r = subprocess.run( - ['osascript', '-e', - f'text returned of (display dialog "{prompt_esc}" ' - f'default answer "{default_esc}" ' - f'with title "{title_esc}" ' - f'buttons {{"Π—Π°ΠΊΡ€Ρ‹Ρ‚ΡŒ", "OK"}} ' - f'default button "OK" cancel button "Π—Π°ΠΊΡ€Ρ‹Ρ‚ΡŒ")'], - capture_output=True, text=True) - if r.returncode != 0: - return None - return r.stdout.rstrip("\r\n") - - -def _on_edit_config(_=None): - threading.Thread(target=_edit_config_dialog, daemon=True).start() - - -# Settings via native macOS dialogs -def _edit_config_dialog(): - cfg = load_config() - - # Host - host = _osascript_input( - "IP-адрСс прокси:", - cfg.get("host", DEFAULT_CONFIG["host"])) - if host is None: - return - host = host.strip() - - import socket as _sock - try: - _sock.inet_aton(host) - except OSError: - _show_error("НСкоррСктный IP-адрСс.") - return - - # Port - port_str = _osascript_input( - "ΠŸΠΎΡ€Ρ‚ прокси:", - str(cfg.get("port", DEFAULT_CONFIG["port"]))) - if port_str is None: - return - try: - port = int(port_str.strip()) - if not (1 <= port <= 65535): - raise ValueError - except ValueError: - _show_error("ΠŸΠΎΡ€Ρ‚ Π΄ΠΎΠ»ΠΆΠ΅Π½ Π±Ρ‹Ρ‚ΡŒ числом 1-65535") - return - - # DC-IP mappings - dc_default = ", ".join(cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"])) - dc_str = _osascript_input( - "DC β†’ IP ΠΌΠ°ΠΏΠΏΠΈΠ½Π³ΠΈ (Ρ‡Π΅Ρ€Π΅Π· Π·Π°ΠΏΡΡ‚ΡƒΡŽ, Ρ„ΠΎΡ€ΠΌΠ°Ρ‚ DC:IP):\n" - "НапримСр: 2:149.154.167.220, 4:149.154.167.220", - dc_default) - if dc_str is None: - return - dc_lines = [s.strip() for s in dc_str.replace(',', '\n').splitlines() - if s.strip()] - try: - tg_ws_proxy.parse_dc_ip_list(dc_lines) - except ValueError as e: - _show_error(str(e)) - return - - # Verbose - verbose = _ask_yes_no_close("Π’ΠΊΠ»ΡŽΡ‡ΠΈΡ‚ΡŒ ΠΏΠΎΠ΄Ρ€ΠΎΠ±Π½ΠΎΠ΅ Π»ΠΎΠ³ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ (verbose)?") - if verbose is None: - return - - # Advanced settings - adv_str = _osascript_input( - "Π Π°ΡΡˆΠΈΡ€Π΅Π½Π½Ρ‹Π΅ настройки (Π±ΡƒΡ„Π΅Ρ€ KB, WS ΠΏΡƒΠ», Π»ΠΎΠ³ MB):\n" - "Π€ΠΎΡ€ΠΌΠ°Ρ‚: buf_kb,pool_size,log_max_mb", - f"{cfg.get('buf_kb', DEFAULT_CONFIG['buf_kb'])}," - f"{cfg.get('pool_size', DEFAULT_CONFIG['pool_size'])}," - f"{cfg.get('log_max_mb', DEFAULT_CONFIG['log_max_mb'])}") - if adv_str is None: - return - - adv = {} - if adv_str: - parts = [s.strip() for s in adv_str.split(',')] - keys = [("buf_kb", int), ("pool_size", int), - ("log_max_mb", float)] - for i, (k, typ) in enumerate(keys): - if i < len(parts): - try: - adv[k] = typ(parts[i]) - except ValueError: - pass - - new_cfg = { - "host": host, - "port": port, - "dc_ip": dc_lines, - "verbose": verbose, - "buf_kb": adv.get("buf_kb", cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"])), - "pool_size": adv.get("pool_size", cfg.get("pool_size", DEFAULT_CONFIG["pool_size"])), - "log_max_mb": adv.get("log_max_mb", cfg.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])), - } - save_config(new_cfg) - log.info("Config saved: %s", new_cfg) - - global _config - _config = new_cfg - if _app: - _app.update_menu_title() - - if _ask_yes_no_close( - "Настройки сохранСны.\n\nΠŸΠ΅Ρ€Π΅Π·Π°ΠΏΡƒΡΡ‚ΠΈΡ‚ΡŒ прокси сСйчас?"): - restart_proxy() - - -# First-run & IPv6 dialogs - -def _show_first_run(): - _ensure_dirs() - if FIRST_RUN_MARKER.exists(): - return - - host = _config.get("host", DEFAULT_CONFIG["host"]) - port = _config.get("port", DEFAULT_CONFIG["port"]) - tg_url = f"tg://socks?server={host}&port={port}" - - text = ( - f"ΠŸΡ€ΠΎΠΊΡΠΈ Π·Π°ΠΏΡƒΡ‰Π΅Π½ ΠΈ Ρ€Π°Π±ΠΎΡ‚Π°Π΅Ρ‚ Π² строкС мСню.\n\n" - f"Как ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡ΠΈΡ‚ΡŒ Telegram Desktop:\n\n" - f"АвтоматичСски:\n" - f" НаТмитС Β«ΠžΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ Π² TelegramΒ» Π² мСню\n" - f" Или ссылка: {tg_url}\n\n" - f"Π’Ρ€ΡƒΡ‡Π½ΡƒΡŽ:\n" - f" Настройки β†’ ΠŸΡ€ΠΎΠ΄Π²ΠΈΠ½ΡƒΡ‚Ρ‹Π΅ β†’ Π’ΠΈΠΏ ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΡ β†’ ΠŸΡ€ΠΎΠΊΡΠΈ\n" - f" SOCKS5 β†’ {host} : {port} (Π±Π΅Π· Π»ΠΎΠ³ΠΈΠ½Π°/пароля)\n\n" - f"ΠžΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ прокси Π² Telegram сСйчас?" - ) - - FIRST_RUN_MARKER.touch() - - if _ask_yes_no(text, "TG WS Proxy"): - _on_open_in_telegram() - - -def _has_ipv6_enabled() -> bool: - import socket as _sock - try: - addrs = _sock.getaddrinfo(_sock.gethostname(), None, _sock.AF_INET6) - for addr in addrs: - ip = addr[4][0] - if ip and not ip.startswith('::1') and not ip.startswith('fe80::1'): - return True - except Exception: - pass - try: - s = _sock.socket(_sock.AF_INET6, _sock.SOCK_STREAM) - s.bind(('::1', 0)) - s.close() - return True - except Exception: - return False - - -def _check_ipv6_warning(): - _ensure_dirs() - if IPV6_WARN_MARKER.exists(): - return - if not _has_ipv6_enabled(): - return - - IPV6_WARN_MARKER.touch() - - _show_info( - "На вашСм ΠΊΠΎΠΌΠΏΡŒΡŽΡ‚Π΅Ρ€Π΅ Π²ΠΊΠ»ΡŽΡ‡Π΅Π½Π° ΠΏΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΊΠ° ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΡ ΠΏΠΎ IPv6.\n\n" - "Telegram ΠΌΠΎΠΆΠ΅Ρ‚ ΠΏΡ‹Ρ‚Π°Ρ‚ΡŒΡΡ ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π°Ρ‚ΡŒΡΡ Ρ‡Π΅Ρ€Π΅Π· IPv6, " - "Ρ‡Ρ‚ΠΎ Π½Π΅ поддСрТиваСтся ΠΈ ΠΌΠΎΠΆΠ΅Ρ‚ привСсти ΠΊ ошибкам.\n\n" - "Если прокси Π½Π΅ Ρ€Π°Π±ΠΎΡ‚Π°Π΅Ρ‚, ΠΏΠΎΠΏΡ€ΠΎΠ±ΡƒΠΉΡ‚Π΅ ΠΎΡ‚ΠΊΠ»ΡŽΡ‡ΠΈΡ‚ΡŒ " - "ΠΏΠΎΠΏΡ‹Ρ‚ΠΊΡƒ соСдинСния ΠΏΠΎ IPv6 Π² настройках прокси Telegram.\n\n" - "Π­Ρ‚ΠΎ ΠΏΡ€Π΅Π΄ΡƒΠΏΡ€Π΅ΠΆΠ΄Π΅Π½ΠΈΠ΅ Π±ΡƒΠ΄Π΅Ρ‚ ΠΏΠΎΠΊΠ°Π·Π°Π½ΠΎ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ ΠΎΠ΄ΠΈΠ½ Ρ€Π°Π·.") - - -# rumps menubar app - -_TgWsProxyAppBase = rumps.App if rumps else object - - -class TgWsProxyApp(_TgWsProxyAppBase): - def __init__(self): - _ensure_menubar_icon() - icon_path = (str(MENUBAR_ICON_PATH) - if MENUBAR_ICON_PATH.exists() else None) - - host = _config.get("host", DEFAULT_CONFIG["host"]) - port = _config.get("port", DEFAULT_CONFIG["port"]) - - self._open_tg_item = rumps.MenuItem( - f"ΠžΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ Π² Telegram ({host}:{port})", - callback=_on_open_in_telegram) - self._restart_item = rumps.MenuItem( - "ΠŸΠ΅Ρ€Π΅Π·Π°ΠΏΡƒΡΡ‚ΠΈΡ‚ΡŒ прокси", - callback=_on_restart) - self._settings_item = rumps.MenuItem( - "Настройки...", - callback=_on_edit_config) - self._logs_item = rumps.MenuItem( - "ΠžΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ Π»ΠΎΠ³ΠΈ", - callback=_on_open_logs) - - super().__init__( - "TG WS Proxy", - icon=icon_path, - template=False, - quit_button="Π’Ρ‹Ρ…ΠΎΠ΄", - menu=[ - self._open_tg_item, - None, - self._restart_item, - self._settings_item, - self._logs_item, - ]) - - def update_menu_title(self): - host = _config.get("host", DEFAULT_CONFIG["host"]) - port = _config.get("port", DEFAULT_CONFIG["port"]) - self._open_tg_item.title = ( - f"ΠžΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ Π² Telegram ({host}:{port})") - - -def run_menubar(): - global _app, _config - - _config = load_config() - save_config(_config) - - if LOG_FILE.exists(): - try: - LOG_FILE.unlink() - except Exception: - pass - - setup_logging(_config.get("verbose", False), - log_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])) - log.info("TG WS Proxy menubar app starting") - log.info("Config: %s", _config) - log.info("Log file: %s", LOG_FILE) - - if rumps is None or Image is None: - log.error("rumps or Pillow not installed; running in console mode") - start_proxy() - try: - while True: - time.sleep(1) - except KeyboardInterrupt: - stop_proxy() - return - - start_proxy() - _show_first_run() - _check_ipv6_warning() - - _app = TgWsProxyApp() - log.info("Menubar app running") - _app.run() - - stop_proxy() - log.info("Menubar app exited") - - -def main(): - if not _acquire_lock(): - _show_info("ΠŸΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ ΡƒΠΆΠ΅ Π·Π°ΠΏΡƒΡ‰Π΅Π½ΠΎ.") - return - - try: - run_menubar() - finally: - _release_lock() - - -if __name__ == "__main__": - main() diff --git a/packaging/linux.spec b/packaging/linux.spec deleted file mode 100644 index ab27315..0000000 --- a/packaging/linux.spec +++ /dev/null @@ -1,80 +0,0 @@ -# -*- mode: python ; coding: utf-8 -*- - -import sys -import os -import glob - -from PyInstaller.utils.hooks import collect_submodules, collect_data_files - -block_cipher = None - -# customtkinter ships JSON themes + assets that must be bundled -import customtkinter -ctk_path = os.path.dirname(customtkinter.__file__) - -# Collect gi (PyGObject) submodules and data so pystray._appindicator works -gi_hiddenimports = collect_submodules('gi') -gi_datas = collect_data_files('gi') - -# Collect GObject typelib files from the system -typelib_dirs = glob.glob('/usr/lib/*/girepository-1.0') -typelib_datas = [] -for d in typelib_dirs: - typelib_datas.append((d, 'gi_typelibs')) - -a = Analysis( - [os.path.join(os.path.dirname(SPEC), os.pardir, 'linux.py')], - pathex=[], - binaries=[], - datas=[(ctk_path, 'customtkinter/')] + gi_datas + typelib_datas, - hiddenimports=[ - 'pystray._appindicator', - 'PIL._tkinter_finder', - 'customtkinter', - 'cryptography.hazmat.primitives.ciphers', - 'cryptography.hazmat.primitives.ciphers.algorithms', - 'cryptography.hazmat.primitives.ciphers.modes', - 'cryptography.hazmat.backends.openssl', - 'gi', - '_gi', - 'gi.repository.GLib', - 'gi.repository.GObject', - 'gi.repository.Gtk', - 'gi.repository.Gdk', - 'gi.repository.AyatanaAppIndicator3', - ] + gi_hiddenimports, - hookspath=[], - hooksconfig={}, - runtime_hooks=[], - excludes=[], - noarchive=False, - cipher=block_cipher, -) - -icon_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'icon.ico') -if os.path.exists(icon_path): - a.datas += [('icon.ico', icon_path, 'DATA')] - -pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) - -exe = EXE( - pyz, - a.scripts, - a.binaries, - a.zipfiles, - a.datas, - [], - name='TgWsProxy', - debug=False, - bootloader_ignore_signals=False, - strip=True, - upx=True, - upx_exclude=[], - runtime_tmpdir=None, - console=False, - disable_windowed_traceback=False, - argv_emulation=False, - target_arch=None, - codesign_identity=None, - entitlements_file=None, -) diff --git a/packaging/macos.spec b/packaging/macos.spec deleted file mode 100644 index 5f38945..0000000 --- a/packaging/macos.spec +++ /dev/null @@ -1,83 +0,0 @@ -# -*- mode: python ; coding: utf-8 -*- - -import sys -import os - -block_cipher = None - -a = Analysis( - [os.path.join(os.path.dirname(SPEC), os.pardir, 'macos.py')], - pathex=[], - binaries=[], - datas=[], - hiddenimports=[ - 'rumps', - 'objc', - 'Foundation', - 'AppKit', - 'PyObjCTools', - 'PyObjCTools.AppHelper', - 'cryptography.hazmat.primitives.ciphers', - 'cryptography.hazmat.primitives.ciphers.algorithms', - 'cryptography.hazmat.primitives.ciphers.modes', - 'cryptography.hazmat.backends.openssl', - ], - hookspath=[], - hooksconfig={}, - runtime_hooks=[], - excludes=[], - noarchive=False, - cipher=block_cipher, -) - -icon_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'icon.icns') -if not os.path.exists(icon_path): - icon_path = None - -pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) - -exe = EXE( - pyz, - a.scripts, - [], - exclude_binaries=True, - name='TgWsProxy', - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=False, - console=False, - argv_emulation=False, - target_arch='universal2', - codesign_identity=None, - entitlements_file=None, -) - -coll = COLLECT( - exe, - a.binaries, - a.zipfiles, - a.datas, - strip=False, - upx=False, - upx_exclude=[], - name='TgWsProxy', -) - -app = BUNDLE( - coll, - name='TG WS Proxy.app', - icon=icon_path, - bundle_identifier='com.tgwsproxy.app', - info_plist={ - 'CFBundleName': 'TG WS Proxy', - 'CFBundleDisplayName': 'TG WS Proxy', - 'CFBundleShortVersionString': '1.0.0', - 'CFBundleVersion': '1.0.0', - 'LSMinimumSystemVersion': '10.15', - 'LSUIElement': True, - 'NSHighResolutionCapable': True, - 'NSAppleEventsUsageDescription': - 'TG WS Proxy needs to display dialogs.', - }, -) diff --git a/packaging/windows.spec b/packaging/windows.spec deleted file mode 100644 index 1c8dd81..0000000 --- a/packaging/windows.spec +++ /dev/null @@ -1,63 +0,0 @@ -# -*- mode: python ; coding: utf-8 -*- - -import sys -import os - -block_cipher = None - -# customtkinter ships JSON themes + assets that must be bundled -import customtkinter -ctk_path = os.path.dirname(customtkinter.__file__) - -a = Analysis( - [os.path.join(os.path.dirname(SPEC), os.pardir, 'windows.py')], - pathex=[], - binaries=[], - datas=[(ctk_path, 'customtkinter/')], - hiddenimports=[ - 'pystray._win32', - 'PIL._tkinter_finder', - 'customtkinter', - 'cryptography.hazmat.primitives.ciphers', - 'cryptography.hazmat.primitives.ciphers.algorithms', - 'cryptography.hazmat.primitives.ciphers.modes', - 'cryptography.hazmat.backends.openssl', - ], - hookspath=[], - hooksconfig={}, - runtime_hooks=[], - excludes=[], - win_no_prefer_redirects=False, - win_private_assemblies=False, - cipher=block_cipher, - noarchive=False, -) - -icon_path = os.path.join(os.path.dirname(SPEC), os.pardir, 'icon.ico') -if os.path.exists(icon_path): - a.datas += [('icon.ico', icon_path, 'DATA')] - -pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) - -exe = EXE( - pyz, - a.scripts, - a.binaries, - a.zipfiles, - a.datas, - [], - name='TgWsProxy', - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=True, - upx_exclude=[], - runtime_tmpdir=None, - console=False, - disable_windowed_traceback=False, - argv_emulation=False, - target_arch=None, - codesign_identity=None, - entitlements_file=None, - icon=icon_path if os.path.exists(icon_path) else None, -) diff --git a/proxy/__init__.py b/proxy/__init__.py deleted file mode 100644 index 9e2406e..0000000 --- a/proxy/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = "1.3.0" \ No newline at end of file diff --git a/proxy/tg_ws_proxy.py b/proxy/tg_ws_proxy.py deleted file mode 100644 index b6e5539..0000000 --- a/proxy/tg_ws_proxy.py +++ /dev/null @@ -1,1193 +0,0 @@ -from __future__ import annotations - -import argparse -import asyncio -import base64 -import logging -import logging.handlers -import os -import socket as _socket -import ssl -import struct -import sys -import time -from typing import Dict, List, Optional, Set, Tuple -from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes - - -DEFAULT_PORT = 1080 -log = logging.getLogger('tg-ws-proxy') - -_TCP_NODELAY = True -_RECV_BUF = 256 * 1024 -_SEND_BUF = 256 * 1024 -_WS_POOL_SIZE = 4 -_WS_POOL_MAX_AGE = 120.0 - -_TG_RANGES = [ - # 185.76.151.0/24 - (struct.unpack('!I', _socket.inet_aton('185.76.151.0'))[0], - struct.unpack('!I', _socket.inet_aton('185.76.151.255'))[0]), - # 149.154.160.0/20 - (struct.unpack('!I', _socket.inet_aton('149.154.160.0'))[0], - struct.unpack('!I', _socket.inet_aton('149.154.175.255'))[0]), - # 91.105.192.0/23 - (struct.unpack('!I', _socket.inet_aton('91.105.192.0'))[0], - struct.unpack('!I', _socket.inet_aton('91.105.193.255'))[0]), - # 91.108.0.0/16 - (struct.unpack('!I', _socket.inet_aton('91.108.0.0'))[0], - struct.unpack('!I', _socket.inet_aton('91.108.255.255'))[0]), -] - -# IP -> (dc_id, is_media) -_IP_TO_DC: Dict[str, Tuple[int, bool]] = { - # DC1 - '149.154.175.50': (1, False), '149.154.175.51': (1, False), - '149.154.175.53': (1, False), '149.154.175.54': (1, False), - '149.154.175.52': (1, True), - # DC2 - '149.154.167.41': (2, False), '149.154.167.50': (2, False), - '149.154.167.51': (2, False), '149.154.167.220': (2, False), - '95.161.76.100': (2, False), - '149.154.167.151': (2, True), '149.154.167.222': (2, True), - '149.154.167.223': (2, True), '149.154.162.123': (2, True), - # DC3 - '149.154.175.100': (3, False), '149.154.175.101': (3, False), - '149.154.175.102': (3, True), - # DC4 - '149.154.167.91': (4, False), '149.154.167.92': (4, False), - '149.154.164.250': (4, True), '149.154.166.120': (4, True), - '149.154.166.121': (4, True), '149.154.167.118': (4, True), - '149.154.165.111': (4, True), - # DC5 - '91.108.56.100': (5, False), '91.108.56.101': (5, False), - '91.108.56.116': (5, False), '91.108.56.126': (5, False), - '149.154.171.5': (5, False), - '91.108.56.102': (5, True), '91.108.56.128': (5, True), - '91.108.56.151': (5, True), - # DC203 - '91.105.192.100': (203, False), -} - -# This case might work but not actually sure -_DC_OVERRIDES: Dict[int, int] = { - 203: 2 -} - -_dc_opt: Dict[int, Optional[str]] = {} - -# DCs where WS is known to fail (302 redirect) -# Raw TCP fallback will be used instead -# Keyed by (dc, is_media) -_ws_blacklist: Set[Tuple[int, bool]] = set() - -# Rate-limit re-attempts per (dc, is_media) -_dc_fail_until: Dict[Tuple[int, bool], float] = {} -_DC_FAIL_COOLDOWN = 30.0 # seconds to keep reduced WS timeout after failure -_WS_FAIL_TIMEOUT = 2.0 # quick-retry timeout after a recent WS failure - -_ZERO_64 = b'\x00' * 64 - - -_ssl_ctx = ssl.create_default_context() -_ssl_ctx.check_hostname = False -_ssl_ctx.verify_mode = ssl.CERT_NONE - - -def _set_sock_opts(transport): - sock = transport.get_extra_info('socket') - if sock is None: - return - if _TCP_NODELAY: - try: - sock.setsockopt(_socket.IPPROTO_TCP, _socket.TCP_NODELAY, 1) - except (OSError, AttributeError): - pass - try: - sock.setsockopt(_socket.SOL_SOCKET, _socket.SO_RCVBUF, _RECV_BUF) - sock.setsockopt(_socket.SOL_SOCKET, _socket.SO_SNDBUF, _SEND_BUF) - except OSError: - pass - - -class WsHandshakeError(Exception): - def __init__(self, status_code: int, status_line: str, - headers: dict = None, location: str = None): - self.status_code = status_code - self.status_line = status_line - self.headers = headers or {} - self.location = location - super().__init__(f"HTTP {status_code}: {status_line}") - - @property - def is_redirect(self) -> bool: - return self.status_code in (301, 302, 303, 307, 308) - - -def _xor_mask(data: bytes, mask: bytes) -> bytes: - if not data: - return data - n = len(data) - mask_rep = (mask * (n // 4 + 1))[:n] - return (int.from_bytes(data, 'big') ^ int.from_bytes(mask_rep, 'big')).to_bytes(n, 'big') - - -# Pre-compiled struct formats -_st_BB = struct.Struct('>BB') -_st_BBH = struct.Struct('>BBH') -_st_BBQ = struct.Struct('>BBQ') -_st_BB4s = struct.Struct('>BB4s') -_st_BBH4s = struct.Struct('>BBH4s') -_st_BBQ4s = struct.Struct('>BBQ4s') -_st_H = struct.Struct('>H') -_st_Q = struct.Struct('>Q') -_st_I_net = struct.Struct('!I') -_st_Ih = struct.Struct(' 'RawWebSocket': - """ - Connect via TLS to the given IP, - perform WebSocket upgrade, return a RawWebSocket. - - Raises WsHandshakeError on non-101 response. - """ - reader, writer = await asyncio.wait_for( - asyncio.open_connection(ip, 443, ssl=_ssl_ctx, - server_hostname=domain), - timeout=min(timeout, 10)) - _set_sock_opts(writer.transport) - - ws_key = base64.b64encode(os.urandom(16)).decode() - req = ( - f'GET {path} HTTP/1.1\r\n' - f'Host: {domain}\r\n' - f'Upgrade: websocket\r\n' - f'Connection: Upgrade\r\n' - f'Sec-WebSocket-Key: {ws_key}\r\n' - f'Sec-WebSocket-Version: 13\r\n' - f'Sec-WebSocket-Protocol: binary\r\n' - f'Origin: https://web.telegram.org\r\n' - f'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' - f'AppleWebKit/537.36 (KHTML, like Gecko) ' - f'Chrome/131.0.0.0 Safari/537.36\r\n' - f'\r\n' - ) - writer.write(req.encode()) - await writer.drain() - - # Read HTTP response headers line-by-line so the reader stays - # positioned right at the start of WebSocket frames. - response_lines: list[str] = [] - try: - while True: - line = await asyncio.wait_for(reader.readline(), - timeout=timeout) - if line in (b'\r\n', b'\n', b''): - break - response_lines.append( - line.decode('utf-8', errors='replace').strip()) - except asyncio.TimeoutError: - writer.close() - raise - - if not response_lines: - writer.close() - raise WsHandshakeError(0, 'empty response') - - first_line = response_lines[0] - parts = first_line.split(' ', 2) - try: - status_code = int(parts[1]) if len(parts) >= 2 else 0 - except ValueError: - status_code = 0 - - if status_code == 101: - return RawWebSocket(reader, writer) - - headers: dict[str, str] = {} - for hl in response_lines[1:]: - if ':' in hl: - k, v = hl.split(':', 1) - headers[k.strip().lower()] = v.strip() - - writer.close() - raise WsHandshakeError(status_code, first_line, headers, - location=headers.get('location')) - - async def send(self, data: bytes): - """Send a masked binary WebSocket frame.""" - if self._closed: - raise ConnectionError("WebSocket closed") - frame = self._build_frame(self.OP_BINARY, data, mask=True) - self.writer.write(frame) - await self.writer.drain() - - async def send_batch(self, parts: List[bytes]): - """Send multiple binary frames with a single drain (less overhead).""" - if self._closed: - raise ConnectionError("WebSocket closed") - for part in parts: - frame = self._build_frame(self.OP_BINARY, part, mask=True) - self.writer.write(frame) - await self.writer.drain() - - async def recv(self) -> Optional[bytes]: - """ - Receive the next data frame. Handles ping/pong/close - internally. Returns payload bytes, or None on clean close. - """ - while not self._closed: - opcode, payload = await self._read_frame() - - if opcode == self.OP_CLOSE: - self._closed = True - try: - reply = self._build_frame( - self.OP_CLOSE, - payload[:2] if payload else b'', - mask=True) - self.writer.write(reply) - await self.writer.drain() - except Exception: - pass - return None - - if opcode == self.OP_PING: - try: - pong = self._build_frame(self.OP_PONG, payload, - mask=True) - self.writer.write(pong) - await self.writer.drain() - except Exception: - pass - continue - - if opcode == self.OP_PONG: - continue - - if opcode in (self.OP_TEXT, self.OP_BINARY): - return payload - - # Unknown opcode β€” skip - continue - - return None - - async def close(self): - """Send close frame and shut down the transport.""" - if self._closed: - return - self._closed = True - try: - self.writer.write( - self._build_frame(self.OP_CLOSE, b'', mask=True)) - await self.writer.drain() - except Exception: - pass - try: - self.writer.close() - await self.writer.wait_closed() - except Exception: - pass - - @staticmethod - def _build_frame(opcode: int, data: bytes, - mask: bool = False) -> bytes: - length = len(data) - fb = 0x80 | opcode - - if not mask: - if length < 126: - return _st_BB.pack(fb, length) + data - if length < 65536: - return _st_BBH.pack(fb, 126, length) + data - return _st_BBQ.pack(fb, 127, length) + data - - mask_key = os.urandom(4) - masked = _xor_mask(data, mask_key) - if length < 126: - return _st_BB4s.pack(fb, 0x80 | length, mask_key) + masked - if length < 65536: - return _st_BBH4s.pack(fb, 0x80 | 126, length, mask_key) + masked - return _st_BBQ4s.pack(fb, 0x80 | 127, length, mask_key) + masked - - async def _read_frame(self) -> Tuple[int, bytes]: - hdr = await self.reader.readexactly(2) - opcode = hdr[0] & 0x0F - length = hdr[1] & 0x7F - - if length == 126: - length = _st_H.unpack( - await self.reader.readexactly(2))[0] - elif length == 127: - length = _st_Q.unpack( - await self.reader.readexactly(8))[0] - - if hdr[1] & 0x80: - mask_key = await self.reader.readexactly(4) - payload = await self.reader.readexactly(length) - return opcode, _xor_mask(payload, mask_key) - - payload = await self.reader.readexactly(length) - return opcode, payload - - -def _human_bytes(n: int) -> str: - for unit in ('B', 'KB', 'MB', 'GB'): - if abs(n) < 1024: - return f"{n:.1f}{unit}" - n /= 1024 - return f"{n:.1f}TB" - - -def _is_telegram_ip(ip: str) -> bool: - try: - n = _st_I_net.unpack(_socket.inet_aton(ip))[0] - return any(lo <= n <= hi for lo, hi in _TG_RANGES) - except OSError: - return False - - -def _is_http_transport(data: bytes) -> bool: - return (data[:5] == b'POST ' or data[:4] == b'GET ' or - data[:5] == b'HEAD ' or data[:8] == b'OPTIONS ') - - -def _dc_from_init(data: bytes) -> Tuple[Optional[int], bool]: - """ - Extract DC ID from the 64-byte MTProto obfuscation init packet. - Returns (dc_id, is_media). - """ - try: - cipher = Cipher(algorithms.AES(data[8:40]), modes.CTR(data[40:56])) - encryptor = cipher.encryptor() - keystream = encryptor.update(_ZERO_64) - plain = (int.from_bytes(data[56:64], 'big') ^ int.from_bytes(keystream[56:64], 'big')).to_bytes(8, 'big') - proto, dc_raw = _st_Ih.unpack(plain[:6]) - log.debug("dc_from_init: proto=0x%08X dc_raw=%d plain=%s", - proto, dc_raw, plain.hex()) - if proto in _VALID_PROTOS: - dc = abs(dc_raw) - if 1 <= dc <= 5 or dc == 203: - return dc, (dc_raw < 0) - except Exception as exc: - log.debug("DC extraction failed: %s", exc) - return None, False - - -def _patch_init_dc(data: bytes, dc: int) -> bytes: - """ - Patch dc_id in the 64-byte MTProto init packet. - - Mobile clients with useSecret=0 leave bytes 60-61 as random. - The WS relay needs a valid dc_id to route correctly. - """ - if len(data) < 64: - return data - - new_dc = struct.pack(' %d", dc) - if len(data) > 64: - return bytes(patched) + data[64:] - return bytes(patched) - except Exception: - return data - - -class _MsgSplitter: - """ - Splits client TCP data into individual MTProto abridged-protocol - messages so each can be sent as a separate WebSocket frame. - - The Telegram WS relay processes one MTProto message per WS frame. - Mobile clients batches multiple messages in a single TCP write (e.g. - msgs_ack + req_DH_params). If sent as one WS frame, the relay - only processes the first message β€” DH handshake never completes. - """ - - def __init__(self, init_data: bytes): - cipher = Cipher(algorithms.AES(init_data[8:40]), - modes.CTR(init_data[40:56])) - self._dec = cipher.encryptor() - self._dec.update(_ZERO_64) # skip init packet - - def split(self, chunk: bytes) -> List[bytes]: - """Decrypt to find message boundaries, return split ciphertext.""" - plain = self._dec.update(chunk) - boundaries = [] - pos = 0 - plain_len = len(plain) - while pos < plain_len: - first = plain[pos] - if first == 0x7f: - if pos + 4 > plain_len: - break - msg_len = ( - _st_I_le.unpack_from(plain, pos + 1)[0] & 0xFFFFFF - ) * 4 - pos += 4 - else: - msg_len = first * 4 - pos += 1 - if msg_len == 0 or pos + msg_len > plain_len: - break - pos += msg_len - boundaries.append(pos) - if len(boundaries) <= 1: - return [chunk] - parts = [] - prev = 0 - for b in boundaries: - parts.append(chunk[prev:b]) - prev = b - if prev < len(chunk): - parts.append(chunk[prev:]) - return parts - - -def _ws_domains(dc: int, is_media) -> List[str]: - dc = _DC_OVERRIDES.get(dc, dc) - if is_media is None or is_media: - return [f'kws{dc}-1.web.telegram.org', f'kws{dc}.web.telegram.org'] - return [f'kws{dc}.web.telegram.org', f'kws{dc}-1.web.telegram.org'] - - -class Stats: - def __init__(self): - self.connections_total = 0 - self.connections_ws = 0 - self.connections_tcp_fallback = 0 - self.connections_http_rejected = 0 - self.connections_passthrough = 0 - self.ws_errors = 0 - self.bytes_up = 0 - self.bytes_down = 0 - self.pool_hits = 0 - self.pool_misses = 0 - - def summary(self) -> str: - return (f"total={self.connections_total} ws={self.connections_ws} " - f"tcp_fb={self.connections_tcp_fallback} " - f"http_skip={self.connections_http_rejected} " - f"pass={self.connections_passthrough} " - f"err={self.ws_errors} " - f"pool={self.pool_hits}/{self.pool_hits+self.pool_misses} " - f"up={_human_bytes(self.bytes_up)} " - f"down={_human_bytes(self.bytes_down)}") - - -_stats = Stats() - - -class _WsPool: - def __init__(self): - self._idle: Dict[Tuple[int, bool], list] = {} - self._refilling: Set[Tuple[int, bool]] = set() - - async def get(self, dc: int, is_media: bool, - target_ip: str, domains: List[str] - ) -> Optional[RawWebSocket]: - key = (dc, is_media) - now = time.monotonic() - - bucket = self._idle.get(key, []) - while bucket: - ws, created = bucket.pop(0) - age = now - created - if age > _WS_POOL_MAX_AGE or ws._closed: - asyncio.create_task(self._quiet_close(ws)) - continue - _stats.pool_hits += 1 - log.debug("WS pool hit for DC%d%s (age=%.1fs, left=%d)", - dc, 'm' if is_media else '', age, len(bucket)) - self._schedule_refill(key, target_ip, domains) - return ws - - _stats.pool_misses += 1 - self._schedule_refill(key, target_ip, domains) - return None - - def _schedule_refill(self, key, target_ip, domains): - if key in self._refilling: - return - self._refilling.add(key) - asyncio.create_task(self._refill(key, target_ip, domains)) - - async def _refill(self, key, target_ip, domains): - dc, is_media = key - try: - bucket = self._idle.setdefault(key, []) - needed = _WS_POOL_SIZE - len(bucket) - if needed <= 0: - return - tasks = [] - for _ in range(needed): - tasks.append(asyncio.create_task( - self._connect_one(target_ip, domains))) - for t in tasks: - try: - ws = await t - if ws: - bucket.append((ws, time.monotonic())) - except Exception: - pass - log.debug("WS pool refilled DC%d%s: %d ready", - dc, 'm' if is_media else '', len(bucket)) - finally: - self._refilling.discard(key) - - @staticmethod - async def _connect_one(target_ip, domains) -> Optional[RawWebSocket]: - for domain in domains: - try: - ws = await RawWebSocket.connect( - target_ip, domain, timeout=8) - return ws - except WsHandshakeError as exc: - if exc.is_redirect: - continue - return None - except Exception: - return None - return None - - @staticmethod - async def _quiet_close(ws): - try: - await ws.close() - except Exception: - pass - - async def warmup(self, dc_opt: Dict[int, Optional[str]]): - """Pre-fill pool for all configured DCs on startup.""" - for dc, target_ip in dc_opt.items(): - if target_ip is None: - continue - for is_media in (False, True): - domains = _ws_domains(dc, is_media) - key = (dc, is_media) - self._schedule_refill(key, target_ip, domains) - log.info("WS pool warmup started for %d DC(s)", len(dc_opt)) - - -_ws_pool = _WsPool() - - -async def _bridge_ws(reader, writer, ws: RawWebSocket, label, - dc=None, dst=None, port=None, is_media=False, - splitter: _MsgSplitter = None): - """Bidirectional TCP <-> WebSocket forwarding.""" - dc_tag = f"DC{dc}{'m' if is_media else ''}" if dc else "DC?" - dst_tag = f"{dst}:{port}" if dst else "?" - - up_bytes = 0 - down_bytes = 0 - up_packets = 0 - down_packets = 0 - start_time = asyncio.get_event_loop().time() - - async def tcp_to_ws(): - nonlocal up_bytes, up_packets - try: - while True: - chunk = await reader.read(65536) - if not chunk: - break - n = len(chunk) - _stats.bytes_up += n - up_bytes += n - up_packets += 1 - if splitter: - parts = splitter.split(chunk) - if len(parts) > 1: - await ws.send_batch(parts) - else: - await ws.send(parts[0]) - else: - await ws.send(chunk) - except (asyncio.CancelledError, ConnectionError, OSError): - return - except Exception as e: - log.debug("[%s] tcp->ws ended: %s", label, e) - - async def ws_to_tcp(): - nonlocal down_bytes, down_packets - try: - while True: - data = await ws.recv() - if data is None: - break - n = len(data) - _stats.bytes_down += n - down_bytes += n - down_packets += 1 - writer.write(data) - await writer.drain() - except (asyncio.CancelledError, ConnectionError, OSError): - return - except Exception as e: - log.debug("[%s] ws->tcp ended: %s", label, e) - - tasks = [asyncio.create_task(tcp_to_ws()), - asyncio.create_task(ws_to_tcp())] - try: - await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) - finally: - for t in tasks: - t.cancel() - for t in tasks: - try: - await t - except BaseException: - pass - elapsed = asyncio.get_event_loop().time() - start_time - log.info("[%s] %s (%s) WS session closed: " - "^%s (%d pkts) v%s (%d pkts) in %.1fs", - label, dc_tag, dst_tag, - _human_bytes(up_bytes), up_packets, - _human_bytes(down_bytes), down_packets, - elapsed) - try: - await ws.close() - except BaseException: - pass - try: - writer.close() - await writer.wait_closed() - except BaseException: - pass - - -async def _bridge_tcp(reader, writer, remote_reader, remote_writer, - label, dc=None, dst=None, port=None, - is_media=False): - """Bidirectional TCP <-> TCP forwarding (for fallback).""" - async def forward(src, dst_w, is_up): - try: - while True: - data = await src.read(65536) - if not data: - break - n = len(data) - if is_up: - _stats.bytes_up += n - else: - _stats.bytes_down += n - dst_w.write(data) - await dst_w.drain() - except asyncio.CancelledError: - pass - except Exception as e: - log.debug("[%s] forward ended: %s", label, e) - - tasks = [ - asyncio.create_task(forward(reader, remote_writer, True)), - asyncio.create_task(forward(remote_reader, writer, False)), - ] - try: - await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) - finally: - for t in tasks: - t.cancel() - for t in tasks: - try: - await t - except BaseException: - pass - for w in (writer, remote_writer): - try: - w.close() - await w.wait_closed() - except BaseException: - pass - - -async def _pipe(r, w): - """Plain TCP relay for non-Telegram traffic.""" - try: - while True: - data = await r.read(65536) - if not data: - break - w.write(data) - await w.drain() - except asyncio.CancelledError: - pass - except Exception: - pass - finally: - try: - w.close() - await w.wait_closed() - except Exception: - pass - - -_SOCKS5_REPLIES = {s: bytes([0x05, s, 0x00, 0x01, 0, 0, 0, 0, 0, 0]) - for s in (0x00, 0x05, 0x07, 0x08)} - - -def _socks5_reply(status): - return _SOCKS5_REPLIES[status] - - -async def _tcp_fallback(reader, writer, dst, port, init, label, - dc=None, is_media=False): - """ - Fall back to direct TCP to the original DC IP. - Throttled by ISP, but functional. Returns True on success. - """ - try: - rr, rw = await asyncio.wait_for( - asyncio.open_connection(dst, port), timeout=10) - except Exception as exc: - log.warning("[%s] TCP fallback connect to %s:%d failed: %s", - label, dst, port, exc) - return False - - _stats.connections_tcp_fallback += 1 - rw.write(init) - await rw.drain() - await _bridge_tcp(reader, writer, rr, rw, label, - dc=dc, dst=dst, port=port, is_media=is_media) - return True - - -async def _handle_client(reader, writer): - _stats.connections_total += 1 - peer = writer.get_extra_info('peername') - label = f"{peer[0]}:{peer[1]}" if peer else "?" - - _set_sock_opts(writer.transport) - - try: - # -- SOCKS5 greeting -- - hdr = await asyncio.wait_for(reader.readexactly(2), timeout=10) - if hdr[0] != 5: - log.debug("[%s] not SOCKS5 (ver=%d)", label, hdr[0]) - writer.close() - return - nmethods = hdr[1] - await reader.readexactly(nmethods) - writer.write(b'\x05\x00') # no-auth - await writer.drain() - - # -- SOCKS5 CONNECT request -- - req = await asyncio.wait_for(reader.readexactly(4), timeout=10) - _ver, cmd, _rsv, atyp = req - if cmd != 1: - writer.write(_socks5_reply(0x07)) - await writer.drain() - writer.close() - return - - if atyp == 1: # IPv4 - raw = await reader.readexactly(4) - dst = _socket.inet_ntoa(raw) - elif atyp == 3: # domain - dlen = (await reader.readexactly(1))[0] - dst = (await reader.readexactly(dlen)).decode() - elif atyp == 4: # IPv6 - raw = await reader.readexactly(16) - dst = _socket.inet_ntop(_socket.AF_INET6, raw) - else: - writer.write(_socks5_reply(0x08)) - await writer.drain() - writer.close() - return - - port = _st_H.unpack(await reader.readexactly(2))[0] - - if ':' in dst: - log.error( - "[%s] IPv6 address detected: %s:%d β€” " - "IPv6 addresses are not supported; " - "disable IPv6 to continue using the proxy.", - label, dst, port) - writer.write(_socks5_reply(0x05)) - await writer.drain() - writer.close() - return - - # -- Non-Telegram IP -> direct passthrough -- - if not _is_telegram_ip(dst): - _stats.connections_passthrough += 1 - log.debug("[%s] passthrough -> %s:%d", label, dst, port) - try: - rr, rw = await asyncio.wait_for( - asyncio.open_connection(dst, port), timeout=10) - except Exception as exc: - log.warning("[%s] passthrough failed to %s: %s: %s", label, dst, type(exc).__name__, str(exc) or "(no message)") - writer.write(_socks5_reply(0x05)) - await writer.drain() - writer.close() - return - - writer.write(_socks5_reply(0x00)) - await writer.drain() - - tasks = [asyncio.create_task(_pipe(reader, rw)), - asyncio.create_task(_pipe(rr, writer))] - await asyncio.wait(tasks, - return_when=asyncio.FIRST_COMPLETED) - for t in tasks: - t.cancel() - for t in tasks: - try: - await t - except BaseException: - pass - return - - # -- Telegram DC: accept SOCKS, read init -- - writer.write(_socks5_reply(0x00)) - await writer.drain() - - try: - init = await asyncio.wait_for( - reader.readexactly(64), timeout=15) - except asyncio.IncompleteReadError: - log.debug("[%s] client disconnected before init", label) - return - - # HTTP transport -> reject - if _is_http_transport(init): - _stats.connections_http_rejected += 1 - log.debug("[%s] HTTP transport to %s:%d (rejected)", - label, dst, port) - writer.close() - return - - # -- Extract DC ID -- - dc, is_media = _dc_from_init(init) - init_patched = False - - # Android (may be ios too) with useSecret=0 has random dc_id bytes β€” patch it - if dc is None and dst in _IP_TO_DC: - dc, is_media = _IP_TO_DC.get(dst) - if dc in _dc_opt: - init = _patch_init_dc(init, dc if is_media else -dc) - init_patched = True - - if dc is None or dc not in _dc_opt: - log.warning("[%s] unknown DC%s for %s:%d -> TCP passthrough", - label, dc, dst, port) - await _tcp_fallback(reader, writer, dst, port, init, label) - return - - dc_key = (dc, is_media if is_media is not None else True) - now = time.monotonic() - media_tag = (" media" if is_media - else (" media?" if is_media is None else "")) - - # -- WS blacklist check -- - if dc_key in _ws_blacklist: - log.debug("[%s] DC%d%s WS blacklisted -> TCP %s:%d", - label, dc, media_tag, dst, port) - ok = await _tcp_fallback(reader, writer, dst, port, init, - label, dc=dc, is_media=is_media) - if ok: - log.info("[%s] DC%d%s TCP fallback closed", - label, dc, media_tag) - return - - # -- Try WebSocket via direct connection -- - fail_until = _dc_fail_until.get(dc_key, 0) - ws_timeout = _WS_FAIL_TIMEOUT if now < fail_until else 10.0 - - domains = _ws_domains(dc, is_media) - target = _dc_opt[dc] - ws = None - ws_failed_redirect = False - all_redirects = True - - ws = await _ws_pool.get(dc, is_media, target, domains) - if ws: - log.info("[%s] DC%d%s (%s:%d) -> pool hit via %s", - label, dc, media_tag, dst, port, target) - else: - for domain in domains: - url = f'wss://{domain}/apiws' - log.info("[%s] DC%d%s (%s:%d) -> %s via %s", - label, dc, media_tag, dst, port, url, target) - try: - ws = await RawWebSocket.connect(target, domain, - timeout=ws_timeout) - all_redirects = False - break - except WsHandshakeError as exc: - _stats.ws_errors += 1 - if exc.is_redirect: - ws_failed_redirect = True - log.warning("[%s] DC%d%s got %d from %s -> %s", - label, dc, media_tag, - exc.status_code, domain, - exc.location or '?') - continue - else: - all_redirects = False - log.warning("[%s] DC%d%s WS handshake: %s", - label, dc, media_tag, exc.status_line) - except Exception as exc: - _stats.ws_errors += 1 - all_redirects = False - err_str = str(exc) - if ('CERTIFICATE_VERIFY_FAILED' in err_str or - 'Hostname mismatch' in err_str): - log.warning("[%s] DC%d%s SSL error: %s", - label, dc, media_tag, exc) - else: - log.warning("[%s] DC%d%s WS connect failed: %s", - label, dc, media_tag, exc) - - # -- WS failed -> fallback -- - if ws is None: - if ws_failed_redirect and all_redirects: - _ws_blacklist.add(dc_key) - log.warning( - "[%s] DC%d%s blacklisted for WS (all 302)", - label, dc, media_tag) - elif ws_failed_redirect: - _dc_fail_until[dc_key] = now + _DC_FAIL_COOLDOWN - else: - _dc_fail_until[dc_key] = now + _DC_FAIL_COOLDOWN - log.info("[%s] DC%d%s WS cooldown for %ds", - label, dc, media_tag, int(_DC_FAIL_COOLDOWN)) - - log.info("[%s] DC%d%s -> TCP fallback to %s:%d", - label, dc, media_tag, dst, port) - ok = await _tcp_fallback(reader, writer, dst, port, init, - label, dc=dc, is_media=is_media) - if ok: - log.info("[%s] DC%d%s TCP fallback closed", - label, dc, media_tag) - return - - # -- WS success -- - _dc_fail_until.pop(dc_key, None) - _stats.connections_ws += 1 - - splitter = None - if init_patched: - try: - splitter = _MsgSplitter(init) - except Exception: - pass - - # Send the buffered init packet - await ws.send(init) - - # Bidirectional bridge - await _bridge_ws(reader, writer, ws, label, - dc=dc, dst=dst, port=port, is_media=is_media, - splitter=splitter) - - except asyncio.TimeoutError: - log.warning("[%s] timeout during SOCKS5 handshake", label) - except asyncio.IncompleteReadError: - log.debug("[%s] client disconnected", label) - except asyncio.CancelledError: - log.debug("[%s] cancelled", label) - except ConnectionResetError: - log.debug("[%s] connection reset", label) - except Exception as exc: - log.error("[%s] unexpected: %s", label, exc) - finally: - try: - writer.close() - except BaseException: - pass - - -_server_instance = None -_server_stop_event = None - - -async def _run(port: int, dc_opt: Dict[int, Optional[str]], - stop_event: Optional[asyncio.Event] = None, - host: str = '127.0.0.1'): - global _dc_opt, _server_instance, _server_stop_event - _dc_opt = dc_opt - _server_stop_event = stop_event - - server = await asyncio.start_server( - _handle_client, host, port) - _server_instance = server - - for sock in server.sockets: - try: - sock.setsockopt(_socket.IPPROTO_TCP, _socket.TCP_NODELAY, 1) - except (OSError, AttributeError): - pass - - log.info("=" * 60) - log.info(" Telegram WS Bridge Proxy") - log.info(" Listening on %s:%d", host, port) - log.info(" Target DC IPs:") - for dc in dc_opt.keys(): - ip = dc_opt.get(dc) - log.info(" DC%d: %s", dc, ip) - log.info("=" * 60) - log.info(" Configure Telegram Desktop:") - log.info(" SOCKS5 proxy -> %s:%d (no user/pass)", host, port) - log.info("=" * 60) - - async def log_stats(): - while True: - await asyncio.sleep(60) - bl = ', '.join( - f'DC{d}{"m" if m else ""}' - for d, m in sorted(_ws_blacklist)) or 'none' - log.info("stats: %s | ws_bl: %s", _stats.summary(), bl) - - asyncio.create_task(log_stats()) - - await _ws_pool.warmup(dc_opt) - - if stop_event: - async def wait_stop(): - await stop_event.wait() - server.close() - me = asyncio.current_task() - for task in list(asyncio.all_tasks()): - if task is not me: - task.cancel() - try: - await server.wait_closed() - except asyncio.CancelledError: - pass - asyncio.create_task(wait_stop()) - - async with server: - try: - await server.serve_forever() - except asyncio.CancelledError: - pass - _server_instance = None - - -def parse_dc_ip_list(dc_ip_list: List[str]) -> Dict[int, str]: - """Parse list of 'DC:IP' strings into {dc: ip} dict.""" - dc_opt: Dict[int, str] = {} - for entry in dc_ip_list: - if ':' not in entry: - raise ValueError(f"Invalid --dc-ip format {entry!r}, expected DC:IP") - dc_s, ip_s = entry.split(':', 1) - try: - dc_n = int(dc_s) - _socket.inet_aton(ip_s) - except (ValueError, OSError): - raise ValueError(f"Invalid --dc-ip {entry!r}") - dc_opt[dc_n] = ip_s - return dc_opt - - -def run_proxy(port: int, dc_opt: Dict[int, str], - stop_event: Optional[asyncio.Event] = None, - host: str = '127.0.0.1'): - """Run the proxy (blocking). Can be called from threads.""" - asyncio.run(_run(port, dc_opt, stop_event, host)) - - -def main(): - ap = argparse.ArgumentParser( - description='Telegram Desktop WebSocket Bridge Proxy') - ap.add_argument('--port', type=int, default=DEFAULT_PORT, - help=f'Listen port (default {DEFAULT_PORT})') - ap.add_argument('--host', type=str, default='127.0.0.1', - help='Listen host (default 127.0.0.1)') - ap.add_argument('--dc-ip', metavar='DC:IP', action='append', - default=[], - help='Target IP for a DC, e.g. --dc-ip 1:149.154.175.205' - ' --dc-ip 2:149.154.167.220') - ap.add_argument('-v', '--verbose', action='store_true', - help='Debug logging') - ap.add_argument('--log-file', type=str, default=None, metavar='PATH', - help='Log to file with rotation (default: stderr only)') - ap.add_argument('--log-max-mb', type=float, default=5, metavar='MB', - help='Max log file size in MB before rotation (default 5)') - ap.add_argument('--log-backups', type=int, default=0, metavar='N', - help='Number of rotated log files to keep (default 0)') - ap.add_argument('--buf-kb', type=int, default=256, metavar='KB', - help='Socket send/recv buffer size in KB (default 256)') - ap.add_argument('--pool-size', type=int, default=4, metavar='N', - help='WS connection pool size per DC (default 4, min 0)') - args = ap.parse_args() - - if not args.dc_ip: - args.dc_ip = ['2:149.154.167.220', '4:149.154.167.220'] - - try: - dc_opt = parse_dc_ip_list(args.dc_ip) - except ValueError as e: - log.error(str(e)) - sys.exit(1) - - log_level = logging.DEBUG if args.verbose else logging.INFO - log_fmt = logging.Formatter('%(asctime)s %(levelname)-5s %(message)s', - datefmt='%H:%M:%S') - root = logging.getLogger() - root.setLevel(log_level) - - console = logging.StreamHandler() - console.setFormatter(log_fmt) - root.addHandler(console) - - if args.log_file: - fh = logging.handlers.RotatingFileHandler( - args.log_file, - maxBytes=max(32 * 1024, args.log_max_mb * 1024 * 1024), - backupCount=max(0, args.log_backups), - encoding='utf-8', - ) - fh.setFormatter(log_fmt) - root.addHandler(fh) - - global _RECV_BUF, _SEND_BUF, _WS_POOL_SIZE - _RECV_BUF = max(4, args.buf_kb) * 1024 - _SEND_BUF = _RECV_BUF - _WS_POOL_SIZE = max(0, args.pool_size) - - try: - asyncio.run(_run(args.port, dc_opt, host=args.host)) - except KeyboardInterrupt: - log.info("Shutting down. Final stats: %s", _stats.summary()) - - -if __name__ == '__main__': - main() diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 0524036..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,73 +0,0 @@ -[build-system] -requires = ["hatchling>=1.25.0"] -build-backend = "hatchling.build" - -[project] -name = "tg-ws-proxy" -dynamic=["version"] - -description = "Telegram Desktop WebSocket Bridge Proxy" -readme = "README.md" -requires-python = ">=3.8" - -license = { name = "MIT", file = "LICENSE" } - -authors = [ - { name = "Flowseal" } -] - -keywords = [ - "telegram", - "tdesktop", - "proxy", - "bypass", - "websocket", - "socks5", -] -classifiers = [ - "Development Status :: 5 - Production/Stable", - "Environment :: Console", - "Intended Audience :: Customer Service", - "Programming Language :: Python :: 3", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Topic :: System :: Networking :: Firewalls", -] - -dependencies = [ - "pyperclip==1.9.0", - - "psutil==5.9.8; platform_system == 'Windows' and python_version < '3.9'", - "cryptography==41.0.7; platform_system == 'Windows' and python_version < '3.9'", - "Pillow==10.4.0; platform_system == 'Windows' and python_version < '3.9'", - - "psutil==7.0.0; platform_system != 'Windows' or python_version >= '3.9'", - "cryptography==46.0.5; platform_system != 'Windows' or python_version >= '3.9'", - "Pillow==12.1.1; (platform_system != 'Windows' or python_version >= '3.9') and platform_system != 'Darwin'", - - "customtkinter==5.2.2; platform_system != 'Darwin'", - "pystray==0.19.5; platform_system != 'Darwin'", - "rumps==0.4.0; platform_system == 'Darwin'", - "Pillow==12.1.0; platform_system == 'Darwin'", -] - -[project.scripts] -tg-ws-proxy = "proxy.tg_ws_proxy:main" -tg-ws-proxy-tray-win = "windows:main" -tg-ws-proxy-tray-macos = "macos:main" -tg-ws-proxy-tray-linux = "linux:main" - -[project.urls] -Source = "https://github.com/Flowseal/tg-ws-proxy" -Issues = "https://github.com/Flowseal/tg-ws-proxy/issues" - -[tool.hatch.build.targets.wheel] -packages = ["proxy"] - -[tool.hatch.build.force-include] -"windows.py" = "windows.py" -"macos.py" = "macos.py" -"linux.py" = "linux.py" - -[tool.hatch.version] -path = "proxy/__init__.py" diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..118ea03 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} +rootProject.name = "TgWsProxy" +include(":app") diff --git a/tg-ws-proxy.go b/tg-ws-proxy.go new file mode 100644 index 0000000..db5af01 --- /dev/null +++ b/tg-ws-proxy.go @@ -0,0 +1,2041 @@ +package main + +/* +#cgo android LDFLAGS: -llog +#include +#include +#ifdef __ANDROID__ +#include +#endif + +static void androidLogProxy(char *msg) { +#ifdef __ANDROID__ + __android_log_print(ANDROID_LOG_INFO, "TgWsProxy", "%s", msg); +#endif +} +*/ +import "C" + +import ( + "bufio" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/tls" + "encoding/base64" + "encoding/binary" + "fmt" + "io" + "log" + "math" + mrand "math/rand/v2" + "net" + "os" + "os/signal" + "runtime" + "strconv" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + "unsafe" +) + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const ( + defaultPort = 1080 + tcpNodelay = true + defaultRecvBuf = 256 * 1024 + defaultSendBuf = 256 * 1024 + defaultPoolSz = 4 + wsPoolMaxAge = 60.0 + wsBridgeIdle = 120.0 + + dcFailCooldown = 30.0 + wsFailTimeout = 2.0 + poolMaintainInterval = 15 +) + +var ( + recvBuf = defaultRecvBuf + sendBuf = defaultSendBuf + poolSize = defaultPoolSz + logVerbose = false +) + +// --------------------------------------------------------------------------- +// Logger +// --------------------------------------------------------------------------- + +var ( + logInfo *log.Logger + logWarn *log.Logger + logError *log.Logger + logDebug *log.Logger +) + +type androidLogWriter struct{} + +func (w androidLogWriter) Write(p []byte) (n int, err error) { + _, _ = os.Stderr.Write(p) + cs := C.CString(string(p)) + C.androidLogProxy(cs) + C.free(unsafe.Pointer(cs)) + return len(p), nil +} + +func initLogging(verbose bool) { + flags := log.Ltime + out := androidLogWriter{} + logInfo = log.New(out, "INFO ", flags) + logWarn = log.New(out, "WARN ", flags) + logError = log.New(out, "ERROR ", flags) + if verbose { + logDebug = log.New(out, "DEBUG ", flags) + } else { + logDebug = log.New(io.Discard, "", 0) + } +} + +// --------------------------------------------------------------------------- +// Telegram IP ranges +// --------------------------------------------------------------------------- + +type ipRange struct { + lo, hi uint32 +} + +var tgRanges []ipRange + +func init() { + ranges := [][2]string{ + {"185.76.151.0", "185.76.151.255"}, + {"149.154.160.0", "149.154.175.255"}, + {"91.105.192.0", "91.105.193.255"}, + {"91.108.0.0", "91.108.255.255"}, + } + for _, r := range ranges { + lo := ipToUint32(net.ParseIP(r[0])) + hi := ipToUint32(net.ParseIP(r[1])) + tgRanges = append(tgRanges, ipRange{lo, hi}) + } +} + +func ipToUint32(ip net.IP) uint32 { + ip4 := ip.To4() + if ip4 == nil { + return 0 + } + return binary.BigEndian.Uint32(ip4) +} + +func isTelegramIP(ipStr string) bool { + ip := net.ParseIP(ipStr) + if ip == nil { + return false + } + n := ipToUint32(ip) + if n == 0 { + return false + } + for _, r := range tgRanges { + if n >= r.lo && n <= r.hi { + return true + } + } + return false +} + +// --------------------------------------------------------------------------- +// IP -> DC mapping +// --------------------------------------------------------------------------- + +type dcInfo struct { + dc int + isMedia bool +} + +var ipToDC = map[string]dcInfo{ + // DC1 + "149.154.175.50": {1, false}, "149.154.175.51": {1, false}, + "149.154.175.53": {1, false}, "149.154.175.54": {1, false}, + "149.154.175.52": {1, true}, + // DC2 + "149.154.167.41": {2, false}, "149.154.167.50": {2, false}, + "149.154.167.51": {2, false}, "149.154.167.220": {2, false}, + "149.154.167.35": {2, false}, "149.154.167.36": {2, false}, + "95.161.76.100": {2, false}, + "149.154.167.151": {2, true}, "149.154.167.222": {2, true}, + "149.154.167.223": {2, true}, "149.154.162.123": {2, true}, + // DC3 + "149.154.175.100": {3, false}, "149.154.175.101": {3, false}, + "149.154.175.102": {3, true}, + // DC4 + "149.154.167.91": {4, false}, "149.154.167.92": {4, false}, + "149.154.164.250": {4, true}, "149.154.166.120": {4, true}, + "149.154.166.121": {4, true}, "149.154.167.118": {4, true}, + "149.154.165.111": {4, true}, + // DC5 + "91.108.56.100": {5, false}, "91.108.56.101": {5, false}, + "91.108.56.116": {5, false}, "91.108.56.126": {5, false}, + "149.154.171.5": {5, false}, + "91.108.56.102": {5, true}, "91.108.56.128": {5, true}, + "91.108.56.151": {5, true}, + // DC203 + "91.105.192.100": {203, false}, +} + +var dcOverrides = map[int]int{ + 203: 2, +} + +var validProtos = map[uint32]bool{ + 0xEFEFEFEF: true, + 0xEEEEEEEE: true, + 0xDDDDDDDD: true, +} + +// --------------------------------------------------------------------------- +// Global state +// --------------------------------------------------------------------------- + +var ( + dcOpt map[int]string + dcOptMu sync.RWMutex + + wsBlackMu sync.RWMutex + wsBlacklist = make(map[[2]int]bool) + + dcFailMu sync.RWMutex + dcFailUntil = make(map[[2]int]float64) + + zero64 = make([]byte, 64) +) + +// --------------------------------------------------------------------------- +// Stats +// --------------------------------------------------------------------------- + +type Stats struct { + connectionsTotal atomic.Int64 + connectionsWs atomic.Int64 + connectionsTcpFallback atomic.Int64 + connectionsHttpReject atomic.Int64 + connectionsPassthrough atomic.Int64 + wsErrors atomic.Int64 + bytesUp atomic.Int64 + bytesDown atomic.Int64 + poolHits atomic.Int64 + poolMisses atomic.Int64 +} + +func (s *Stats) Summary() string { + ph := s.poolHits.Load() + pm := s.poolMisses.Load() + return fmt.Sprintf( + "total=%d ws=%d tcp_fb=%d http_skip=%d pass=%d err=%d pool=%d/%d up=%s down=%s", + s.connectionsTotal.Load(), + s.connectionsWs.Load(), + s.connectionsTcpFallback.Load(), + s.connectionsHttpReject.Load(), + s.connectionsPassthrough.Load(), + s.wsErrors.Load(), + ph, ph+pm, + humanBytes(s.bytesUp.Load()), + humanBytes(s.bytesDown.Load()), + ) +} + +func (s *Stats) Reset() { + s.connectionsTotal.Store(0) + s.connectionsWs.Store(0) + s.connectionsTcpFallback.Store(0) + s.connectionsHttpReject.Store(0) + s.connectionsPassthrough.Store(0) + s.wsErrors.Store(0) + s.bytesUp.Store(0) + s.bytesDown.Store(0) + s.poolHits.Store(0) + s.poolMisses.Store(0) +} + +var stats Stats + +func humanBytes(n int64) string { + abs := n + if abs < 0 { + abs = -abs + } + units := []string{"B", "KB", "MB", "GB", "TB"} + f := float64(n) + for i, u := range units { + if math.Abs(f) < 1024 || i == len(units)-1 { + return fmt.Sprintf("%.1f%s", f, u) + } + f /= 1024 + } + return fmt.Sprintf("%.1f%s", f, "TB") +} + +// --------------------------------------------------------------------------- +// Socket helpers +// --------------------------------------------------------------------------- + +func setSockOpts(conn net.Conn) { + tc, ok := conn.(*net.TCPConn) + if !ok { + return + } + if tcpNodelay { + _ = tc.SetNoDelay(true) + } + raw, err := tc.SyscallConn() + if err != nil { + return + } + _ = raw.Control(func(fd uintptr) { + _ = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_RCVBUF, recvBuf) + _ = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_SNDBUF, sendBuf) + }) +} + +// --------------------------------------------------------------------------- +// XOR mask β€” optimized 8-byte processing +// --------------------------------------------------------------------------- + +func xorMask(data, mask []byte) []byte { + n := len(data) + if n == 0 { + return data + } + + result := make([]byte, n) + + // Build 8-byte mask + mask8 := uint64(mask[0]) | uint64(mask[1])<<8 | + uint64(mask[2])<<16 | uint64(mask[3])<<24 | + uint64(mask[0])<<32 | uint64(mask[1])<<40 | + uint64(mask[2])<<48 | uint64(mask[3])<<56 + + i := 0 + // Process 8 bytes at a time + for ; i+8 <= n; i += 8 { + v := binary.LittleEndian.Uint64(data[i:]) + binary.LittleEndian.PutUint64(result[i:], v^mask8) + } + // Process remaining bytes + for ; i < n; i++ { + result[i] = data[i] ^ mask[i&3] + } + return result +} + +// xorMaskInPlace modifies data in place +func xorMaskInPlace(data, mask []byte) { + n := len(data) + if n == 0 { + return + } + + mask8 := uint64(mask[0]) | uint64(mask[1])<<8 | + uint64(mask[2])<<16 | uint64(mask[3])<<24 | + uint64(mask[0])<<32 | uint64(mask[1])<<40 | + uint64(mask[2])<<48 | uint64(mask[3])<<56 + + i := 0 + for ; i+8 <= n; i += 8 { + v := binary.LittleEndian.Uint64(data[i:]) + binary.LittleEndian.PutUint64(data[i:], v^mask8) + } + for ; i < n; i++ { + data[i] ^= mask[i&3] + } +} + +// --------------------------------------------------------------------------- +// WsHandshakeError +// --------------------------------------------------------------------------- + +type WsHandshakeError struct { + StatusCode int + StatusLine string + Headers map[string]string + Location string +} + +func (e *WsHandshakeError) Error() string { + return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.StatusLine) +} + +func (e *WsHandshakeError) IsRedirect() bool { + switch e.StatusCode { + case 301, 302, 303, 307, 308: + return true + } + return false +} + +// --------------------------------------------------------------------------- +// RawWebSocket +// --------------------------------------------------------------------------- + +const ( + opContinuation = 0x0 + opText = 0x1 + opBinary = 0x2 + opClose = 0x8 + opPing = 0x9 + opPong = 0xA +) + +type RawWebSocket struct { + conn net.Conn + bufReader *bufio.Reader + writeMu sync.Mutex + closed atomic.Bool +} + +func wsConnect(ip, domain, path string, timeout float64) (*RawWebSocket, error) { + if path == "" { + path = "/apiws" + } + if timeout <= 0 { + timeout = 10.0 + } + + dialTimeout := timeout + if dialTimeout > 10.0 { + dialTimeout = 10.0 + } + + dialer := &net.Dialer{ + Timeout: time.Duration(dialTimeout * float64(time.Second)), + } + + tlsCfg := &tls.Config{ + InsecureSkipVerify: true, + ServerName: domain, + } + + rawConn, err := tls.DialWithDialer(dialer, "tcp", ip+":443", tlsCfg) + if err != nil { + return nil, err + } + + setSockOpts(rawConn) + + wsKeyBytes := make([]byte, 16) + _, _ = rand.Read(wsKeyBytes) + wsKey := base64.StdEncoding.EncodeToString(wsKeyBytes) + + req := fmt.Sprintf( + "GET %s HTTP/1.1\r\n"+ + "Host: %s\r\n"+ + "Upgrade: websocket\r\n"+ + "Connection: Upgrade\r\n"+ + "Sec-WebSocket-Key: %s\r\n"+ + "Sec-WebSocket-Version: 13\r\n"+ + "Sec-WebSocket-Protocol: binary\r\n"+ + "Origin: https://web.telegram.org\r\n"+ + "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) "+ + "AppleWebKit/537.36 (KHTML, like Gecko) "+ + "Chrome/131.0.0.0 Safari/537.36\r\n"+ + "\r\n", + path, domain, wsKey, + ) + + _ = rawConn.SetWriteDeadline(time.Now().Add(time.Duration(timeout * float64(time.Second)))) + _, err = rawConn.Write([]byte(req)) + if err != nil { + rawConn.Close() + return nil, err + } + _ = rawConn.SetWriteDeadline(time.Time{}) + + // Use buffered reader for efficient header parsing + bufReader := bufio.NewReaderSize(rawConn, 4096) + + _ = rawConn.SetReadDeadline(time.Now().Add(time.Duration(timeout * float64(time.Second)))) + + var responseLines []string + for { + line, err := bufReader.ReadString('\n') + if err != nil { + rawConn.Close() + return nil, err + } + line = strings.TrimRight(line, "\r\n") + if line == "" { + break + } + responseLines = append(responseLines, line) + if len(responseLines) > 100 { + rawConn.Close() + return nil, fmt.Errorf("too many HTTP headers") + } + } + _ = rawConn.SetReadDeadline(time.Time{}) + + if len(responseLines) == 0 { + rawConn.Close() + return nil, &WsHandshakeError{StatusCode: 0, StatusLine: "empty response"} + } + + firstLine := responseLines[0] + parts := strings.SplitN(firstLine, " ", 3) + statusCode := 0 + if len(parts) >= 2 { + statusCode, _ = strconv.Atoi(parts[1]) + } + + if statusCode == 101 { + ws := &RawWebSocket{ + conn: rawConn, + bufReader: bufReader, + } + return ws, nil + } + + headers := make(map[string]string) + for _, hl := range responseLines[1:] { + idx := strings.IndexByte(hl, ':') + if idx >= 0 { + k := strings.TrimSpace(strings.ToLower(hl[:idx])) + v := strings.TrimSpace(hl[idx+1:]) + headers[k] = v + } + } + rawConn.Close() + return nil, &WsHandshakeError{ + StatusCode: statusCode, + StatusLine: firstLine, + Headers: headers, + Location: headers["location"], + } +} + +func (ws *RawWebSocket) Send(data []byte) error { + if ws.closed.Load() { + return fmt.Errorf("WebSocket closed") + } + frame := ws.buildFrame(opBinary, data, true) + ws.writeMu.Lock() + _, err := ws.conn.Write(frame) + ws.writeMu.Unlock() + return err +} + +func (ws *RawWebSocket) SendBatch(parts [][]byte) error { + if ws.closed.Load() { + return fmt.Errorf("WebSocket closed") + } + ws.writeMu.Lock() + defer ws.writeMu.Unlock() + for _, part := range parts { + frame := ws.buildFrame(opBinary, part, true) + if _, err := ws.conn.Write(frame); err != nil { + return err + } + } + return nil +} + +func (ws *RawWebSocket) SendPing() error { + if ws.closed.Load() { + return fmt.Errorf("WebSocket closed") + } + frame := ws.buildFrame(opPing, nil, true) + ws.writeMu.Lock() + _, err := ws.conn.Write(frame) + ws.writeMu.Unlock() + return err +} + +func (ws *RawWebSocket) Recv() ([]byte, error) { + for !ws.closed.Load() { + opcode, payload, err := ws.readFrame() + if err != nil { + ws.closed.Store(true) + return nil, err + } + + switch opcode { + case opClose: + ws.closed.Store(true) + closePayload := payload + if len(closePayload) > 2 { + closePayload = closePayload[:2] + } + reply := ws.buildFrame(opClose, closePayload, true) + ws.writeMu.Lock() + _, _ = ws.conn.Write(reply) + ws.writeMu.Unlock() + return nil, io.EOF + + case opPing: + pong := ws.buildFrame(opPong, payload, true) + ws.writeMu.Lock() + _, _ = ws.conn.Write(pong) + ws.writeMu.Unlock() + continue + + case opPong: + continue + + case opText, opBinary: + return payload, nil + + default: + continue + } + } + return nil, io.EOF +} + +func (ws *RawWebSocket) Close() { + if ws.closed.Swap(true) { + return + } + frame := ws.buildFrame(opClose, nil, true) + ws.writeMu.Lock() + _, _ = ws.conn.Write(frame) + ws.writeMu.Unlock() + _ = ws.conn.Close() +} + +// SetReadDeadline exposes deadline control for the bridge +func (ws *RawWebSocket) SetReadDeadline(t time.Time) error { + return ws.conn.SetReadDeadline(t) +} + +// buildFrame creates a WebSocket frame with minimal allocations +func (ws *RawWebSocket) buildFrame(opcode int, data []byte, mask bool) []byte { + length := len(data) + fb := byte(0x80 | opcode) + + // Calculate total size + headerSize := 2 + if mask { + headerSize += 4 + } + if length >= 126 && length < 65536 { + headerSize += 2 + } else if length >= 65536 { + headerSize += 8 + } + + totalSize := headerSize + length + result := make([]byte, totalSize) + pos := 0 + + result[pos] = fb + pos++ + + var maskKey [4]byte + if mask { + _, _ = rand.Read(maskKey[:]) + } + + if length < 126 { + lb := byte(length) + if mask { + lb |= 0x80 + } + result[pos] = lb + pos++ + } else if length < 65536 { + lb := byte(126) + if mask { + lb |= 0x80 + } + result[pos] = lb + pos++ + binary.BigEndian.PutUint16(result[pos:], uint16(length)) + pos += 2 + } else { + lb := byte(127) + if mask { + lb |= 0x80 + } + result[pos] = lb + pos++ + binary.BigEndian.PutUint64(result[pos:], uint64(length)) + pos += 8 + } + + if mask { + copy(result[pos:], maskKey[:]) + pos += 4 + // XOR directly into result buffer + payloadStart := pos + copy(result[payloadStart:], data) + xorMaskInPlace(result[payloadStart:payloadStart+length], maskKey[:]) + } else { + copy(result[pos:], data) + } + + return result +} + +func (ws *RawWebSocket) readFrame() (int, []byte, error) { + hdr := make([]byte, 2) + if _, err := io.ReadFull(ws.bufReader, hdr); err != nil { + return 0, nil, err + } + + opcode := int(hdr[0] & 0x0F) + length := uint64(hdr[1] & 0x7F) + + if length == 126 { + buf := make([]byte, 2) + if _, err := io.ReadFull(ws.bufReader, buf); err != nil { + return 0, nil, err + } + length = uint64(binary.BigEndian.Uint16(buf)) + } else if length == 127 { + buf := make([]byte, 8) + if _, err := io.ReadFull(ws.bufReader, buf); err != nil { + return 0, nil, err + } + length = binary.BigEndian.Uint64(buf) + } + + hasMask := (hdr[1] & 0x80) != 0 + var maskKey []byte + if hasMask { + maskKey = make([]byte, 4) + if _, err := io.ReadFull(ws.bufReader, maskKey); err != nil { + return 0, nil, err + } + } + + payload := make([]byte, length) + if length > 0 { + if _, err := io.ReadFull(ws.bufReader, payload); err != nil { + return 0, nil, err + } + } + + if hasMask { + xorMaskInPlace(payload, maskKey) + } + + return opcode, payload, nil +} + +// --------------------------------------------------------------------------- +// Crypto helpers: DC extraction & patching +// --------------------------------------------------------------------------- + +func newAESCTR(key, iv []byte) (cipher.Stream, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + return cipher.NewCTR(block, iv), nil +} + +func dcFromInit(data []byte) (dc int, isMedia bool, ok bool) { + if len(data) < 64 { + return 0, false, false + } + + stream, err := newAESCTR(data[8:40], data[40:56]) + if err != nil { + logDebug.Printf("DC extraction failed: %v", err) + return 0, false, false + } + + keystream := make([]byte, 64) + stream.XORKeyStream(keystream, zero64) + + plain := make([]byte, 8) + for i := 0; i < 8; i++ { + plain[i] = data[56+i] ^ keystream[56+i] + } + + proto := binary.LittleEndian.Uint32(plain[0:4]) + dcRaw := int16(binary.LittleEndian.Uint16(plain[4:6])) + + logDebug.Printf("dc_from_init: proto=0x%08X dc_raw=%d plain=%x", proto, dcRaw, plain) + + if !validProtos[proto] { + return 0, false, false + } + + dcAbs := int(dcRaw) + if dcAbs < 0 { + dcAbs = -dcAbs + } + media := dcRaw < 0 + + if (dcAbs >= 1 && dcAbs <= 5) || dcAbs == 203 { + return dcAbs, media, true + } + + return 0, false, false +} + +func patchInitDC(data []byte, dc int) []byte { + if len(data) < 64 { + return data + } + + newDC := make([]byte, 2) + binary.LittleEndian.PutUint16(newDC, uint16(int16(dc))) + + stream, err := newAESCTR(data[8:40], data[40:56]) + if err != nil { + return data + } + + ks := make([]byte, 64) + stream.XORKeyStream(ks, zero64) + + patched := make([]byte, len(data)) + copy(patched, data) + patched[60] = ks[60] ^ newDC[0] + patched[61] = ks[61] ^ newDC[1] + + logDebug.Printf("init patched: dc_id -> %d", dc) + return patched +} + +// --------------------------------------------------------------------------- +// MsgSplitter +// --------------------------------------------------------------------------- + +type MsgSplitter struct { + stream cipher.Stream +} + +func newMsgSplitter(initData []byte) (*MsgSplitter, error) { + if len(initData) < 56 { + return nil, fmt.Errorf("init data too short") + } + stream, err := newAESCTR(initData[8:40], initData[40:56]) + if err != nil { + return nil, err + } + skip := make([]byte, 64) + stream.XORKeyStream(skip, zero64) + + return &MsgSplitter{stream: stream}, nil +} + +func (s *MsgSplitter) Split(chunk []byte) [][]byte { + plain := make([]byte, len(chunk)) + s.stream.XORKeyStream(plain, chunk) + + var boundaries []int + pos := 0 + plainLen := len(plain) + + for pos < plainLen { + first := plain[pos] + var msgLen int + if first == 0x7f { + if pos+4 > plainLen { + break + } + msgLen = int(uint32(plain[pos+1]) | uint32(plain[pos+2])<<8 | uint32(plain[pos+3])<<16) + msgLen *= 4 + pos += 4 + } else { + msgLen = int(first) * 4 + pos++ + } + if msgLen == 0 || pos+msgLen > plainLen { + break + } + pos += msgLen + boundaries = append(boundaries, pos) + } + + if len(boundaries) <= 1 { + return [][]byte{chunk} + } + + parts := make([][]byte, 0, len(boundaries)+1) + prev := 0 + for _, b := range boundaries { + parts = append(parts, chunk[prev:b]) + prev = b + } + if prev < len(chunk) { + parts = append(parts, chunk[prev:]) + } + return parts +} + +// --------------------------------------------------------------------------- +// WS domains +// --------------------------------------------------------------------------- + +func wsDomains(dc int, isMedia *bool) []string { + effectiveDC := dc + if override, ok := dcOverrides[dc]; ok { + effectiveDC = override + } + + if isMedia == nil || *isMedia { + return []string{ + fmt.Sprintf("kws%d-1.web.telegram.org", effectiveDC), + fmt.Sprintf("kws%d.web.telegram.org", effectiveDC), + } + } + return []string{ + fmt.Sprintf("kws%d.web.telegram.org", effectiveDC), + fmt.Sprintf("kws%d-1.web.telegram.org", effectiveDC), + } +} + +// --------------------------------------------------------------------------- +// WsPool +// --------------------------------------------------------------------------- + +type poolEntry struct { + ws *RawWebSocket + created float64 +} + +type WsPool struct { + mu sync.Mutex + idle map[[2]int][]poolEntry + refilling map[[2]int]bool +} + +func newWsPool() *WsPool { + return &WsPool{ + idle: make(map[[2]int][]poolEntry), + refilling: make(map[[2]int]bool), + } +} + +func isMediaInt(b bool) int { + if b { + return 1 + } + return 0 +} + +func monoNow() float64 { + return float64(time.Now().UnixNano()) / 1e9 +} + +func (p *WsPool) Get(dc int, isMedia bool, targetIP string, domains []string) *RawWebSocket { + key := [2]int{dc, isMediaInt(isMedia)} + now := monoNow() + + p.mu.Lock() + defer p.mu.Unlock() + + bucket := p.idle[key] + for len(bucket) > 0 { + entry := bucket[0] + bucket = bucket[1:] + p.idle[key] = bucket + + age := now - entry.created + if age > wsPoolMaxAge || entry.ws.closed.Load() { + go entry.ws.Close() + continue + } + + stats.poolHits.Add(1) + logDebug.Printf("WS pool hit for DC%d%s (age=%.1fs, left=%d)", + dc, mediaTag(isMedia), age, len(bucket)) + p.scheduleRefillLocked(key, targetIP, domains) + return entry.ws + } + + stats.poolMisses.Add(1) + p.scheduleRefillLocked(key, targetIP, domains) + return nil +} + +// scheduleRefillLocked must be called with p.mu held +func (p *WsPool) scheduleRefillLocked(key [2]int, targetIP string, domains []string) { + if p.refilling[key] { + return + } + p.refilling[key] = true + go p.refill(key, targetIP, domains) +} + +func (p *WsPool) refill(key [2]int, targetIP string, domains []string) { + dc := key[0] + isMedia := key[1] == 1 + + defer func() { + p.mu.Lock() + delete(p.refilling, key) + p.mu.Unlock() + }() + + p.mu.Lock() + bucket := p.idle[key] + needed := poolSize - len(bucket) + p.mu.Unlock() + + if needed <= 0 { + return + } + + type result struct { + ws *RawWebSocket + } + + ch := make(chan result, needed) + for i := 0; i < needed; i++ { + go func() { + ws := connectOneWS(targetIP, domains) + ch <- result{ws} + }() + } + + for i := 0; i < needed; i++ { + r := <-ch + if r.ws != nil { + p.mu.Lock() + p.idle[key] = append(p.idle[key], poolEntry{r.ws, monoNow()}) + p.mu.Unlock() + } + } + + p.mu.Lock() + logDebug.Printf("WS pool refilled DC%d%s: %d ready", + dc, mediaTag(isMedia), len(p.idle[key])) + p.mu.Unlock() +} + +func connectOneWS(targetIP string, domains []string) *RawWebSocket { + for _, domain := range domains { + ws, err := wsConnect(targetIP, domain, "/apiws", 8) + if err != nil { + if wsErr, ok := err.(*WsHandshakeError); ok && wsErr.IsRedirect() { + continue + } + return nil + } + return ws + } + return nil +} + +func (p *WsPool) Warmup(dcOptMap map[int]string) { + p.mu.Lock() + defer p.mu.Unlock() + + for dc, targetIP := range dcOptMap { + if targetIP == "" { + continue + } + for _, isMedia := range []bool{false, true} { + domains := wsDomains(dc, &isMedia) + key := [2]int{dc, isMediaInt(isMedia)} + p.scheduleRefillLocked(key, targetIP, domains) + } + } + logInfo.Printf("WS pool warmup started for %d DC(s)", len(dcOptMap)) +} + +func (p *WsPool) Maintain(ctx context.Context, dcOptMap map[int]string) { + ticker := time.NewTicker(poolMaintainInterval * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + p.maintainOnce(dcOptMap) + } + } +} + +func (p *WsPool) maintainOnce(dcOptMap map[int]string) { + now := monoNow() + + p.mu.Lock() + for key, bucket := range p.idle { + var fresh []poolEntry + for _, e := range bucket { + age := now - e.created + if age > wsPoolMaxAge || e.ws.closed.Load() { + go e.ws.Close() + } else { + // Send ping to keep connection alive + go func(ws *RawWebSocket) { + if err := ws.SendPing(); err != nil { + ws.Close() + } + }(e.ws) + fresh = append(fresh, e) + } + } + p.idle[key] = fresh + } + p.mu.Unlock() + + // Refill all known DCs + p.mu.Lock() + for dc, targetIP := range dcOptMap { + if targetIP == "" { + continue + } + for _, isMedia := range []bool{false, true} { + domains := wsDomains(dc, &isMedia) + key := [2]int{dc, isMediaInt(isMedia)} + p.scheduleRefillLocked(key, targetIP, domains) + } + } + p.mu.Unlock() +} + +func (p *WsPool) IdleCount() int { + p.mu.Lock() + defer p.mu.Unlock() + count := 0 + for _, bucket := range p.idle { + count += len(bucket) + } + return count +} + +func (p *WsPool) CloseAll() { + p.mu.Lock() + defer p.mu.Unlock() + for key, bucket := range p.idle { + for _, e := range bucket { + go e.ws.Close() + } + delete(p.idle, key) + } +} + +var wsPool = newWsPool() + +// --------------------------------------------------------------------------- +// Helper tags +// --------------------------------------------------------------------------- + +func mediaTag(isMedia bool) string { + if isMedia { + return "m" + } + return "" +} + +// --------------------------------------------------------------------------- +// HTTP detection +// --------------------------------------------------------------------------- + +func isHTTPTransport(data []byte) bool { + if len(data) < 4 { + return false + } + return string(data[:4]) == "POST" || + string(data[:3]) == "GET" || + string(data[:4]) == "HEAD" || + string(data[:7]) == "OPTIONS" +} + +// --------------------------------------------------------------------------- +// SOCKS5 reply +// --------------------------------------------------------------------------- + +var socks5Replies = map[byte][]byte{ + 0x00: {0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0}, + 0x05: {0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0}, + 0x07: {0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0}, + 0x08: {0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0}, +} + +func socks5Reply(status byte) []byte { + if r, ok := socks5Replies[status]; ok { + return r + } + return []byte{0x05, status, 0x00, 0x01, 0, 0, 0, 0, 0, 0} +} + +// --------------------------------------------------------------------------- +// Bridging: TCP <-> WebSocket +// --------------------------------------------------------------------------- + +func bridgeWS(ctx context.Context, conn net.Conn, ws *RawWebSocket, + label string, dc int, dst string, port int, isMedia bool, + splitter *MsgSplitter) { + + dcTag := fmt.Sprintf("DC%d%s", dc, mediaTag(isMedia)) + dstTag := fmt.Sprintf("%s:%d", dst, port) + + var upBytes, downBytes, upPkts, downPkts int64 + startTime := time.Now() + + ctx2, cancel := context.WithCancel(ctx) + + // Critical: close connections when context is cancelled + // This unblocks the Read() calls in goroutines + go func() { + <-ctx2.Done() + _ = conn.Close() + ws.Close() + }() + + var wg sync.WaitGroup + wg.Add(2) + + // tcp -> ws + go func() { + defer wg.Done() + defer cancel() + buf := make([]byte, 65536) + for { + n, err := conn.Read(buf) + if n > 0 { + chunk := buf[:n] + stats.bytesUp.Add(int64(n)) + upBytes += int64(n) + upPkts++ + + var sendErr error + if splitter != nil { + parts := splitter.Split(chunk) + if len(parts) > 1 { + sendErr = ws.SendBatch(parts) + } else { + sendErr = ws.Send(parts[0]) + } + } else { + sendErr = ws.Send(chunk) + } + if sendErr != nil { + return + } + } + if err != nil { + return + } + } + }() + + // ws -> tcp + go func() { + defer wg.Done() + defer cancel() + for { + data, err := ws.Recv() + if err != nil || data == nil { + return + } + n := len(data) + stats.bytesDown.Add(int64(n)) + downBytes += int64(n) + downPkts++ + if _, err := conn.Write(data); err != nil { + return + } + } + }() + + wg.Wait() + + elapsed := time.Since(startTime).Seconds() + logInfo.Printf("[%s] %s (%s) WS session closed: ^%s (%d pkts) v%s (%d pkts) in %.1fs", + label, dcTag, dstTag, + humanBytes(upBytes), upPkts, + humanBytes(downBytes), downPkts, + elapsed) +} + +// --------------------------------------------------------------------------- +// Bridging: TCP <-> TCP (fallback) +// --------------------------------------------------------------------------- + +func bridgeTCP(ctx context.Context, client, remote net.Conn, + label string, dc int, dst string, port int, isMedia bool) { + + ctx2, cancel := context.WithCancel(ctx) + + // Close connections when context cancelled + go func() { + <-ctx2.Done() + _ = client.Close() + _ = remote.Close() + }() + + var wg sync.WaitGroup + wg.Add(2) + + forward := func(src, dstW net.Conn, isUp bool) { + defer wg.Done() + defer cancel() + buf := make([]byte, 65536) + for { + n, err := src.Read(buf) + if n > 0 { + if isUp { + stats.bytesUp.Add(int64(n)) + } else { + stats.bytesDown.Add(int64(n)) + } + if _, werr := dstW.Write(buf[:n]); werr != nil { + return + } + } + if err != nil { + return + } + } + } + + go forward(client, remote, true) + go forward(remote, client, false) + + wg.Wait() +} + +// --------------------------------------------------------------------------- +// TCP fallback +// --------------------------------------------------------------------------- + +func tcpFallback(ctx context.Context, client net.Conn, dst string, port int, + init []byte, label string, dc int, isMedia bool) bool { + + dialer := &net.Dialer{Timeout: 10 * time.Second} + remote, err := dialer.DialContext(ctx, "tcp", fmt.Sprintf("%s:%d", dst, port)) + if err != nil { + logWarn.Printf("[%s] TCP fallback connect to %s:%d failed: %v", + label, dst, port, err) + return false + } + + stats.connectionsTcpFallback.Add(1) + _, _ = remote.Write(init) + bridgeTCP(ctx, client, remote, label, dc, dst, port, isMedia) + return true +} + +// --------------------------------------------------------------------------- +// Pipe (non-Telegram passthrough) +// --------------------------------------------------------------------------- + +func pipe(ctx context.Context, src, dst net.Conn, done chan<- struct{}) { + defer func() { done <- struct{}{} }() + buf := make([]byte, 65536) + for { + select { + case <-ctx.Done(): + return + default: + } + n, err := src.Read(buf) + if n > 0 { + if _, werr := dst.Write(buf[:n]); werr != nil { + return + } + } + if err != nil { + return + } + } +} + +// --------------------------------------------------------------------------- +// SOCKS5 client handler +// --------------------------------------------------------------------------- + +func readExactly(conn net.Conn, n int, timeout time.Duration) ([]byte, error) { + if timeout > 0 { + _ = conn.SetReadDeadline(time.Now().Add(timeout)) + defer func() { _ = conn.SetReadDeadline(time.Time{}) }() + } + buf := make([]byte, n) + _, err := io.ReadFull(conn, buf) + return buf, err +} + +func handleClient(ctx context.Context, conn net.Conn) { + stats.connectionsTotal.Add(1) + peer := conn.RemoteAddr().String() + label := peer + + setSockOpts(conn) + + defer conn.Close() + + // -- SOCKS5 greeting -- + hdr, err := readExactly(conn, 2, 10*time.Second) + if err != nil { + logDebug.Printf("[%s] read greeting failed: %v", label, err) + return + } + if hdr[0] != 5 { + logDebug.Printf("[%s] not SOCKS5 (ver=%d)", label, hdr[0]) + return + } + nmethods := int(hdr[1]) + if _, err := readExactly(conn, nmethods, 10*time.Second); err != nil { + return + } + if _, err := conn.Write([]byte{0x05, 0x00}); err != nil { + return + } + + // -- SOCKS5 CONNECT request -- + req, err := readExactly(conn, 4, 10*time.Second) + if err != nil { + return + } + cmd := req[1] + atyp := req[3] + + if cmd != 1 { + _, _ = conn.Write(socks5Reply(0x07)) + return + } + + var dst string + switch atyp { + case 1: // IPv4 + raw, err := readExactly(conn, 4, 10*time.Second) + if err != nil { + return + } + dst = net.IP(raw).String() + case 3: // domain + dlenBuf, err := readExactly(conn, 1, 10*time.Second) + if err != nil { + return + } + domBytes, err := readExactly(conn, int(dlenBuf[0]), 10*time.Second) + if err != nil { + return + } + dst = string(domBytes) + case 4: // IPv6 + raw, err := readExactly(conn, 16, 10*time.Second) + if err != nil { + return + } + dst = net.IP(raw).String() + default: + _, _ = conn.Write(socks5Reply(0x08)) + return + } + + portBuf, err := readExactly(conn, 2, 10*time.Second) + if err != nil { + return + } + port := int(binary.BigEndian.Uint16(portBuf)) + + if strings.Contains(dst, ":") { + logError.Printf("[%s] IPv6 address detected: %s:%d β€” "+ + "IPv6 addresses are not supported; "+ + "disable IPv6 to continue using the proxy.", + label, dst, port) + _, _ = conn.Write(socks5Reply(0x05)) + return + } + + // -- Non-Telegram IP -> direct passthrough -- + if !isTelegramIP(dst) { + stats.connectionsPassthrough.Add(1) + logDebug.Printf("[%s] passthrough -> %s:%d", label, dst, port) + + dialer := &net.Dialer{Timeout: 10 * time.Second} + remote, err := dialer.DialContext(ctx, "tcp", fmt.Sprintf("%s:%d", dst, port)) + if err != nil { + logWarn.Printf("[%s] passthrough failed to %s: %T: %v", label, dst, err, err) + _, _ = conn.Write(socks5Reply(0x05)) + return + } + + _, _ = conn.Write(socks5Reply(0x00)) + + ctx2, cancel := context.WithCancel(ctx) + defer cancel() + + // Close connections when context done + go func() { + <-ctx2.Done() + _ = conn.Close() + _ = remote.Close() + }() + + done := make(chan struct{}, 2) + go pipe(ctx2, conn, remote, done) + go pipe(ctx2, remote, conn, done) + <-done + cancel() + <-done + _ = remote.Close() + return + } + + // -- Telegram DC: accept SOCKS, read init -- + _, _ = conn.Write(socks5Reply(0x00)) + + init, err := readExactly(conn, 64, 15*time.Second) + if err != nil { + logDebug.Printf("[%s] client disconnected before init: %v", label, err) + return + } + + // HTTP transport -> reject + if isHTTPTransport(init) { + stats.connectionsHttpReject.Add(1) + logDebug.Printf("[%s] HTTP transport to %s:%d (rejected)", label, dst, port) + return + } + + // -- Extract DC ID -- + dc, isMedia, dcOk := dcFromInit(init) + initPatched := false + var isMediaPtr *bool + if dcOk { + isMediaPtr = &isMedia + } + + // Android with useSecret=0 has random dc_id bytes β€” patch it + if !dcOk { + if info, found := ipToDC[dst]; found { + dc = info.dc + isMedia = info.isMedia + isMediaPtr = &isMedia + dcOk = true + + dcOptMu.RLock() + _, hasDC := dcOpt[dc] + dcOptMu.RUnlock() + + if hasDC { + // media -> positive dc, non-media -> negative dc + signedDC := -dc + if isMedia { + signedDC = dc + } + init = patchInitDC(init, signedDC) + initPatched = true + } + } + } + + dcOptMu.RLock() + _, dcConfigured := dcOpt[dc] + dcOptMu.RUnlock() + + if !dcOk || !dcConfigured { + logDebug.Printf("[%s] unknown DC%d for %s:%d -> TCP passthrough", label, dc, dst, port) + tcpFallback(ctx, conn, dst, port, init, label, dc, isMedia) + return + } + + dcKey := [2]int{dc, isMediaInt(isMedia)} + now := monoNow() + + mTag := "" + if isMediaPtr == nil { + mTag = " media?" + } else if *isMediaPtr { + mTag = " media" + } + + // -- WS blacklist check -- + wsBlackMu.RLock() + blacklisted := wsBlacklist[dcKey] + wsBlackMu.RUnlock() + + if blacklisted { + logDebug.Printf("[%s] DC%d%s WS blacklisted -> TCP %s:%d", + label, dc, mTag, dst, port) + ok := tcpFallback(ctx, conn, dst, port, init, label, dc, isMedia) + if ok { + logInfo.Printf("[%s] DC%d%s TCP fallback closed", label, dc, mTag) + } + return + } + + // -- Try WebSocket -- + dcFailMu.RLock() + failUntil := dcFailUntil[dcKey] + dcFailMu.RUnlock() + + wsTimeout := 10.0 + if now < failUntil { + wsTimeout = wsFailTimeout + } + + isMediaForDomains := isMedia + domains := wsDomains(dc, &isMediaForDomains) + + dcOptMu.RLock() + target := dcOpt[dc] + dcOptMu.RUnlock() + + var ws *RawWebSocket + wsFailedRedirect := false + allRedirects := true + + ws = wsPool.Get(dc, isMedia, target, domains) + if ws != nil { + logInfo.Printf("[%s] DC%d%s (%s:%d) -> pool hit via %s", + label, dc, mTag, dst, port, target) + } else { + for _, domain := range domains { + url := fmt.Sprintf("wss://%s/apiws", domain) + logInfo.Printf("[%s] DC%d%s (%s:%d) -> %s via %s", + label, dc, mTag, dst, port, url, target) + + var connErr error + ws, connErr = wsConnect(target, domain, "/apiws", wsTimeout) + if connErr == nil { + allRedirects = false + break + } + + stats.wsErrors.Add(1) + + if wsErr, ok := connErr.(*WsHandshakeError); ok { + if wsErr.IsRedirect() { + wsFailedRedirect = true + logWarn.Printf("[%s] DC%d%s got %d from %s -> %s", + label, dc, mTag, wsErr.StatusCode, domain, + wsErr.Location) + continue + } + allRedirects = false + logWarn.Printf("[%s] DC%d%s WS handshake: %s", + label, dc, mTag, wsErr.StatusLine) + } else { + allRedirects = false + errStr := connErr.Error() + if strings.Contains(errStr, "certificate") || + strings.Contains(errStr, "hostname") { + logWarn.Printf("[%s] DC%d%s SSL error: %v", + label, dc, mTag, connErr) + } else { + logWarn.Printf("[%s] DC%d%s WS connect failed: %v", + label, dc, mTag, connErr) + } + } + } + } + + // -- WS failed -> fallback -- + if ws == nil { + if wsFailedRedirect && allRedirects { + wsBlackMu.Lock() + wsBlacklist[dcKey] = true + wsBlackMu.Unlock() + logWarn.Printf("[%s] DC%d%s blacklisted for WS (all 302)", + label, dc, mTag) + } else if wsFailedRedirect { + dcFailMu.Lock() + dcFailUntil[dcKey] = now + dcFailCooldown + dcFailMu.Unlock() + } else { + dcFailMu.Lock() + dcFailUntil[dcKey] = now + dcFailCooldown + dcFailMu.Unlock() + logInfo.Printf("[%s] DC%d%s WS cooldown for %ds", + label, dc, mTag, int(dcFailCooldown)) + } + + logInfo.Printf("[%s] DC%d%s -> TCP fallback to %s:%d", + label, dc, mTag, dst, port) + ok := tcpFallback(ctx, conn, dst, port, init, label, dc, isMedia) + if ok { + logInfo.Printf("[%s] DC%d%s TCP fallback closed", label, dc, mTag) + } + return + } + + // -- WS success -- + dcFailMu.Lock() + delete(dcFailUntil, dcKey) + dcFailMu.Unlock() + + stats.connectionsWs.Add(1) + + var splitter *MsgSplitter + if initPatched { + splitter, _ = newMsgSplitter(init) + } + + // Send init packet + if err := ws.Send(init); err != nil { + logDebug.Printf("[%s] reconnecting via TCP fallback (WS broken): %v", label, err) + ws.Close() + tcpFallback(ctx, conn, dst, port, init, label, dc, isMedia) + return + } + + // Bidirectional bridge + bridgeWS(ctx, conn, ws, label, dc, dst, port, isMedia, splitter) +} + +// --------------------------------------------------------------------------- +// Server +// --------------------------------------------------------------------------- + +func runProxy(ctx context.Context, host string, port int, dcOptMap map[int]string) error { + dcOptMu.Lock() + dcOpt = dcOptMap + dcOptMu.Unlock() + + addr := fmt.Sprintf("%s:%d", host, port) + lc := net.ListenConfig{} + + listener, err := lc.Listen(ctx, "tcp", addr) + if err != nil { + return fmt.Errorf("listen on %s: %w", addr, err) + } + + if tcpL, ok := listener.(*net.TCPListener); ok { + raw, err := tcpL.SyscallConn() + if err == nil { + _ = raw.Control(func(fd uintptr) { + _ = syscall.SetsockoptInt(int(fd), syscall.IPPROTO_TCP, syscall.TCP_NODELAY, 1) + }) + } + } + + srvCtx, srvCancel := context.WithCancel(ctx) + defer srvCancel() + + logInfo.Println(strings.Repeat("=", 60)) + logInfo.Println(" Telegram WS Bridge Proxy (Go)") + logInfo.Printf(" Listening on %s:%d", host, port) + logInfo.Println(" Target DC IPs:") + for dc, ip := range dcOptMap { + logInfo.Printf(" DC%d: %s", dc, ip) + } + logInfo.Println(strings.Repeat("=", 60)) + logInfo.Printf(" Configure Telegram Desktop:") + logInfo.Printf(" SOCKS5 proxy -> %s:%d (no user/pass)", host, port) + logInfo.Println(strings.Repeat("=", 60)) + + // Stats logger + go func() { + ticker := time.NewTicker(60 * time.Second) + defer ticker.Stop() + for { + select { + case <-srvCtx.Done(): + return + case <-ticker.C: + wsBlackMu.RLock() + var blParts []string + for k := range wsBlacklist { + m := "" + if k[1] == 1 { + m = "m" + } + blParts = append(blParts, fmt.Sprintf("DC%d%s", k[0], m)) + } + wsBlackMu.RUnlock() + bl := "none" + if len(blParts) > 0 { + bl = strings.Join(blParts, ", ") + } + idleCount := wsPool.IdleCount() + logInfo.Printf("stats: %s idle=%d | ws_bl: %s", stats.Summary(), idleCount, bl) + } + } + }() + + // Warmup WS pool + wsPool.Warmup(dcOptMap) + + // Periodic pool maintenance + go wsPool.Maintain(srvCtx, dcOptMap) + + // Track active connections for graceful shutdown + var activeConns sync.WaitGroup + + // Accept loop + go func() { + for { + conn, err := listener.Accept() + if err != nil { + select { + case <-srvCtx.Done(): + return + default: + if ne, ok := err.(net.Error); ok && ne.Timeout() { + continue + } + logError.Printf("accept error: %v", err) + return + } + } + activeConns.Add(1) + go func() { + defer activeConns.Done() + handleClient(srvCtx, conn) + }() + } + }() + + // Wait for context cancellation + <-srvCtx.Done() + logInfo.Println("Shutting down proxy server...") + _ = listener.Close() + + // Wait for active connections with timeout + done := make(chan struct{}) + go func() { + activeConns.Wait() + close(done) + }() + + select { + case <-done: + logInfo.Println("All connections closed gracefully") + case <-time.After(30 * time.Second): + logWarn.Println("Graceful shutdown timed out after 30s") + } + + // Close pool connections + wsPool.CloseAll() + + logInfo.Printf("Final stats: %s", stats.Summary()) + return nil +} + +// --------------------------------------------------------------------------- +// Parse DC:IP list / CIDR pool +// --------------------------------------------------------------------------- + +func randomIPFromCIDR(cidr string) (string, error) { + // If it's just an IP (no /), return it as-is + if !strings.Contains(cidr, "/") { + if ip := net.ParseIP(cidr); ip != nil { + return cidr, nil + } + return "", fmt.Errorf("invalid IP: %s", cidr) + } + + ip, ipnet, err := net.ParseCIDR(cidr) + if err != nil { + return "", err + } + ip = ip.To4() + if ip == nil { + return "", fmt.Errorf("not ipv4") + } + + start := binary.BigEndian.Uint32(ip) + mask := binary.BigEndian.Uint32(ipnet.Mask) + + wildcard := ^mask + offset := uint32(1) + if wildcard > 1 { + offset = 1 + mrand.Uint32N(wildcard-1) + } + + randIP := start + offset + res := make(net.IP, 4) + binary.BigEndian.PutUint32(res, randIP) + return res.String(), nil +} + +func parseCIDRPool(cidrsStr string) (map[int]string, error) { + result := make(map[int]string) + ranges := strings.Split(cidrsStr, ",") + var validCIDRs []string + for _, r := range ranges { + r = strings.TrimSpace(r) + if r != "" { + validCIDRs = append(validCIDRs, r) + } + } + if len(validCIDRs) == 0 { + validCIDRs = []string{"149.154.167.220/32"} + } + + dcs := []int{1, 2, 3, 4, 5, 203} + for _, dc := range dcs { + cidr := validCIDRs[mrand.IntN(len(validCIDRs))] + ipStr, err := randomIPFromCIDR(cidr) + if err == nil { + result[dc] = ipStr + } else if net.ParseIP(cidr) != nil { + result[dc] = cidr + } else { + result[dc] = "149.154.167.220" + } + } + return result, nil +} + +// --------------------------------------------------------------------------- +// CGO exports for Android .so +// --------------------------------------------------------------------------- + +var ( + globalCtx context.Context + globalCancel context.CancelFunc + globalMu sync.Mutex +) + +//export StartProxy +func StartProxy(cHost *C.char, port C.int, cDcIps *C.char, verbose C.int) C.int { + globalMu.Lock() + defer globalMu.Unlock() + + if globalCancel != nil { + return -1 // Already running + } + + host := C.GoString(cHost) + goPort := int(port) + dcIpsStr := C.GoString(cDcIps) + isVerbose := int(verbose) != 0 + + initLogging(isVerbose) + + dcOptMap, err := parseCIDRPool(dcIpsStr) + if err != nil { + logError.Printf("parseCIDRPool: %v", err) + return -2 + } + + globalCtx, globalCancel = context.WithCancel(context.Background()) + + go func() { + if err := runProxy(globalCtx, host, goPort, dcOptMap); err != nil { + logError.Printf("runProxy error: %v", err) + } + }() + + return 0 +} + +//export StopProxy +func StopProxy() C.int { + globalMu.Lock() + defer globalMu.Unlock() + + if globalCancel == nil { + return -1 + } + + globalCancel() + globalCancel = nil + globalCtx = nil + + // Reset state + stats.Reset() + + wsBlackMu.Lock() + wsBlacklist = make(map[[2]int]bool) + wsBlackMu.Unlock() + + dcFailMu.Lock() + dcFailUntil = make(map[[2]int]float64) + dcFailMu.Unlock() + + return 0 +} + +//export SetPoolSize +func SetPoolSize(size C.int) { + n := int(size) + if n < 2 { + n = 2 + } + if n > 16 { + n = 16 + } + poolSize = n + if logInfo != nil { + logInfo.Printf("Pool size set to %d", n) + } +} + +//export GetStats +func GetStats() *C.char { + s := stats.Summary() + return C.CString(s) +} + +//export FreeString +func FreeString(p *C.char) { + C.free(unsafe.Pointer(p)) +} + +// --------------------------------------------------------------------------- +// Standalone main +// --------------------------------------------------------------------------- + +func main() { + runtime.LockOSThread() + + initLogging(false) + + dcOptMap := map[int]string{ + 2: "149.154.167.220", + 4: "149.154.167.220", + } + + host := "127.0.0.1" + port := defaultPort + + args := os.Args[1:] + for i := 0; i < len(args); i++ { + switch args[i] { + case "--port": + if i+1 < len(args) { + i++ + p, err := strconv.Atoi(args[i]) + if err == nil { + port = p + } + } + case "--host": + if i+1 < len(args) { + i++ + host = args[i] + } + case "-v", "--verbose": + initLogging(true) + case "--dc-ip": + if i+1 < len(args) { + i++ + entry := args[i] + parsed, err := parseCIDRPool(entry) + if err != nil { + logError.Printf("%v", err) + os.Exit(1) + } + for k, v := range parsed { + dcOptMap[k] = v + } + } + } + } + + ctx, cancel := context.WithCancel(context.Background()) + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + sig := <-sigCh + logInfo.Printf("Received signal %v, shutting down...", sig) + cancel() + }() + + if err := runProxy(ctx, host, port, dcOptMap); err != nil { + logError.Printf("Fatal: %v", err) + os.Exit(1) + } +} diff --git a/windows.py b/windows.py deleted file mode 100644 index 6eaad3f..0000000 --- a/windows.py +++ /dev/null @@ -1,842 +0,0 @@ -from __future__ import annotations - -import ctypes -import json -import logging -import logging.handlers -import os -import winreg -import psutil -import sys -import threading -import time -import webbrowser -import pyperclip -import asyncio as _asyncio -from pathlib import Path -from typing import Dict, Optional - -import pystray -import customtkinter as ctk -from PIL import Image, ImageDraw, ImageFont - -import proxy.tg_ws_proxy as tg_ws_proxy - - -IS_FROZEN = bool(getattr(sys, "frozen", False)) - -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, - "autostart": False, - "log_max_mb": 5, - "buf_kb": 256, - "pool_size": 4, -} - - -_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") - - -def _same_process(lock_meta: dict, proc: psutil.Process) -> bool: - try: - lock_ct = float(lock_meta.get("create_time", 0.0)) - proc_ct = float(proc.create_time()) - if lock_ct > 0 and abs(lock_ct - proc_ct) > 1.0: - return False - except Exception: - return False - - frozen = bool(getattr(sys, "frozen", False)) - if frozen: - return os.path.basename(sys.executable) == proc.name() - - return False - - -def _release_lock(): - global _lock_file_path - if not _lock_file_path: - return - try: - _lock_file_path.unlink(missing_ok=True) - except Exception: - pass - _lock_file_path = None - - -def _acquire_lock() -> bool: - global _lock_file_path - _ensure_dirs() - lock_files = list(APP_DIR.glob("*.lock")) - - for f in lock_files: - pid = None - meta: dict = {} - - try: - pid = int(f.stem) - except Exception: - f.unlink(missing_ok=True) - continue - - try: - raw = f.read_text(encoding="utf-8").strip() - if raw: - meta = json.loads(raw) - except Exception: - meta = {} - - try: - proc = psutil.Process(pid) - if _same_process(meta, proc): - return False - except Exception: - pass - - f.unlink(missing_ok=True) - - lock_file = APP_DIR / f"{os.getpid()}.lock" - try: - proc = psutil.Process(os.getpid()) - payload = { - "create_time": proc.create_time(), - } - lock_file.write_text(json.dumps(payload, ensure_ascii=False), - encoding="utf-8") - except Exception: - lock_file.touch() - - _lock_file_path = lock_file - return True - - -def _ensure_dirs(): - APP_DIR.mkdir(parents=True, exist_ok=True) - - -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) - - -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) - - -def setup_logging(verbose: bool = False, log_max_mb: float = 5): - _ensure_dirs() - root = logging.getLogger() - root.setLevel(logging.DEBUG if verbose else logging.INFO) - - fh = logging.handlers.RotatingFileHandler( - str(LOG_FILE), - maxBytes=max(32 * 1024, log_max_mb * 1024 * 1024), - backupCount=0, - 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 _autostart_reg_name() -> str: - return APP_NAME - - -def _supports_autostart() -> bool: - return IS_FROZEN - - -def _autostart_command() -> str: - return f'"{sys.executable}"' - - -def is_autostart_enabled() -> bool: - try: - with winreg.OpenKey( - winreg.HKEY_CURRENT_USER, - r"Software\Microsoft\Windows\CurrentVersion\Run", - 0, - winreg.KEY_READ, - ) as k: - val, _ = winreg.QueryValueEx(k, _autostart_reg_name()) - stored = str(val).strip() - expected = _autostart_command().strip() - return stored == expected - except FileNotFoundError: - return False - except OSError: - return False - - -def set_autostart_enabled(enabled: bool) -> None: - try: - with winreg.CreateKey( - winreg.HKEY_CURRENT_USER, - r"Software\Microsoft\Windows\CurrentVersion\Run", - ) as k: - if enabled: - winreg.SetValueEx( - k, - _autostart_reg_name(), - 0, - winreg.REG_SZ, - _autostart_command(), - ) - else: - try: - winreg.DeleteValue(k, _autostart_reg_name()) - except FileNotFoundError: - pass - except OSError as exc: - log.error("Failed to update autostart: %s", exc) - _show_error( - "НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ ΠΈΠ·ΠΌΠ΅Π½ΠΈΡ‚ΡŒ автозапуск.\n\n" - "ΠŸΠΎΠΏΡ€ΠΎΠ±ΡƒΠΉΡ‚Π΅ Π·Π°ΠΏΡƒΡΡ‚ΠΈΡ‚ΡŒ ΠΏΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ ΠΎΡ‚ ΠΈΠΌΠ΅Π½ΠΈ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ с ΠΏΡ€Π°Π²Π°ΠΌΠΈ Π½Π° рССстр.\n\n" - f"Ошибка: {exc}" - ) - - -def _make_icon_image(size: int = 64): - if Image is None: - raise RuntimeError("Pillow is required for tray icon") - img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) - draw = ImageDraw.Draw(img) - - margin = 2 - draw.ellipse([margin, margin, size - margin, size - margin], - fill=(0, 136, 204, 255)) - - try: - font = ImageFont.truetype("arial.ttf", 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] - tx = (size - tw) // 2 - bbox[0] - ty = (size - th) // 2 - bbox[1] - draw.text((tx, ty), "T", fill=(255, 255, 255, 255), font=font) - - return img - - -def _load_icon(): - icon_path = Path(__file__).parent / "icon.ico" - if icon_path.exists() and Image: - try: - return Image.open(str(icon_path)) - except Exception: - 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) - - buf_kb = cfg.get("buf_kb", DEFAULT_CONFIG["buf_kb"]) - pool_size = cfg.get("pool_size", DEFAULT_CONFIG["pool_size"]) - tg_ws_proxy._RECV_BUF = max(4, buf_kb) * 1024 - tg_ws_proxy._SEND_BUF = tg_ws_proxy._RECV_BUF - tg_ws_proxy._WS_POOL_SIZE = max(0, pool_size) - - _proxy_thread = threading.Thread( - target=_run_proxy_thread, - args=(port, dc_opt, verbose, host), - daemon=True, name="proxy") - _proxy_thread.start() - - -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") - - -def restart_proxy(): - log.info("Restarting proxy...") - stop_proxy() - time.sleep(0.3) - start_proxy() - - -def _show_error(text: str, title: str = "TG WS Proxy β€” Ошибка"): - ctypes.windll.user32.MessageBoxW(0, text, title, 0x10) - - -def _show_info(text: str, title: str = "TG WS Proxy"): - ctypes.windll.user32.MessageBoxW(0, text, title, 0x40) - - -def _on_open_in_telegram(icon=None, item=None): - port = _config.get("port", DEFAULT_CONFIG["port"]) - url = f"tg://socks?server=127.0.0.1&port={port}" - log.info("Opening %s", url) - try: - result = webbrowser.open(url) - if not result: - raise RuntimeError("webbrowser.open returned False") - except Exception: - log.info("Browser open failed, copying to clipboard") - try: - pyperclip.copy(url) - _show_info( - f"НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ ΠΎΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ Telegram автоматичСски.\n\n" - f"Бсылка скопирована Π² Π±ΡƒΡ„Π΅Ρ€ ΠΎΠ±ΠΌΠ΅Π½Π°, ΠΎΡ‚ΠΏΡ€Π°Π²ΡŒΡ‚Π΅ Π΅Ρ‘ Π² Telegram ΠΈ Π½Π°ΠΆΠΌΠΈΡ‚Π΅ ΠΏΠΎ Π½Π΅ΠΉ Π›ΠšΠœ:\n{url}", - "TG WS Proxy") - except Exception as exc: - log.error("Clipboard copy failed: %s", exc) - _show_error(f"НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ ΡΠΊΠΎΠΏΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ ссылку:\n{exc}") - - -def _on_restart(icon=None, item=None): - threading.Thread(target=restart_proxy, daemon=True).start() - - -def _on_edit_config(icon=None, item=None): - threading.Thread(target=_edit_config_dialog, daemon=True).start() - - -def _edit_config_dialog(): - if ctk is None: - _show_error("customtkinter Π½Π΅ установлСн.") - return - - cfg = dict(_config) - cfg["autostart"] = is_autostart_enabled() - - # Make sure that the autostart key is removed if autostart - # is disabled, even if the executable file is moved. - if _supports_autostart() and not cfg["autostart"]: - set_autostart_enabled(False) - - ctk.set_appearance_mode("light") - ctk.set_default_color_theme("blue") - - root = ctk.CTk() - root.title("TG WS Proxy β€” Настройки") - root.resizable(False, False) - root.attributes("-topmost", True) - icon_path = str(Path(__file__).parent / "icon.ico") - root.iconbitmap(icon_path) - - TG_BLUE = "#3390ec" - TG_BLUE_HOVER = "#2b7cd4" - BG = "#ffffff" - FIELD_BG = "#f0f2f5" - FIELD_BORDER = "#d6d9dc" - TEXT_PRIMARY = "#000000" - TEXT_SECONDARY = "#707579" - FONT_FAMILY = "Segoe UI" - - w, h = 420, 540 - - if _supports_autostart(): - h += 70 - - sw = root.winfo_screenwidth() - sh = root.winfo_screenheight() - root.geometry(f"{w}x{h}+{(sw-w)//2}+{(sh-h)//2}") - root.configure(fg_color=BG) - - frame = ctk.CTkFrame(root, fg_color=BG, corner_radius=0) - frame.pack(fill="both", expand=True, padx=24, pady=20) - - # Host - ctk.CTkLabel(frame, text="IP-адрСс прокси", - font=(FONT_FAMILY, 13), text_color=TEXT_PRIMARY, - anchor="w").pack(anchor="w", pady=(0, 4)) - host_var = ctk.StringVar(value=cfg.get("host", "127.0.0.1")) - host_entry = ctk.CTkEntry(frame, textvariable=host_var, width=200, height=36, - font=(FONT_FAMILY, 13), corner_radius=10, - fg_color=FIELD_BG, border_color=FIELD_BORDER, - border_width=1, text_color=TEXT_PRIMARY) - host_entry.pack(anchor="w", pady=(0, 12)) - - # Port - ctk.CTkLabel(frame, text="ΠŸΠΎΡ€Ρ‚ прокси", - font=(FONT_FAMILY, 13), text_color=TEXT_PRIMARY, - anchor="w").pack(anchor="w", pady=(0, 4)) - port_var = ctk.StringVar(value=str(cfg.get("port", 1080))) - port_entry = ctk.CTkEntry(frame, textvariable=port_var, width=120, height=36, - font=(FONT_FAMILY, 13), corner_radius=10, - fg_color=FIELD_BG, border_color=FIELD_BORDER, - border_width=1, text_color=TEXT_PRIMARY) - port_entry.pack(anchor="w", pady=(0, 12)) - - # DC-IP mappings - ctk.CTkLabel(frame, text="DC β†’ IP ΠΌΠ°ΠΏΠΏΠΈΠ½Π³ΠΈ (ΠΏΠΎ ΠΎΠ΄Π½ΠΎΠΌΡƒ Π½Π° строку, Ρ„ΠΎΡ€ΠΌΠ°Ρ‚ DC:IP)", - font=(FONT_FAMILY, 13), text_color=TEXT_PRIMARY, - anchor="w").pack(anchor="w", pady=(0, 4)) - dc_textbox = ctk.CTkTextbox(frame, width=370, height=120, - font=("Consolas", 12), corner_radius=10, - fg_color=FIELD_BG, border_color=FIELD_BORDER, - border_width=1, text_color=TEXT_PRIMARY) - dc_textbox.pack(anchor="w", pady=(0, 12)) - dc_textbox.insert("1.0", "\n".join(cfg.get("dc_ip", DEFAULT_CONFIG["dc_ip"]))) - - # Verbose - verbose_var = ctk.BooleanVar(value=cfg.get("verbose", False)) - ctk.CTkCheckBox(frame, text="ΠŸΠΎΠ΄Ρ€ΠΎΠ±Π½ΠΎΠ΅ Π»ΠΎΠ³ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ (verbose)", - variable=verbose_var, font=(FONT_FAMILY, 13), - text_color=TEXT_PRIMARY, - fg_color=TG_BLUE, hover_color=TG_BLUE_HOVER, - corner_radius=6, border_width=2, - border_color=FIELD_BORDER).pack(anchor="w", pady=(0, 8)) - - # Advanced: buf_kb, pool_size, log_max_mb - adv_frame = ctk.CTkFrame(frame, fg_color="transparent") - adv_frame.pack(anchor="w", fill="x", pady=(4, 8)) - - for col, (lbl, key, w_) in enumerate([ - ("Π‘ΡƒΡ„Π΅Ρ€ (KB, 256 default)", "buf_kb", 120), - ("WS ΠΏΡƒΠ»ΠΎΠ² (4 default)", "pool_size", 120), - ("Log size (MB, 5 def)", "log_max_mb", 120), - ]): - col_frame = ctk.CTkFrame(adv_frame, fg_color="transparent") - col_frame.pack(side="left", padx=(0, 10)) - ctk.CTkLabel(col_frame, text=lbl, font=(FONT_FAMILY, 11), - text_color=TEXT_SECONDARY, anchor="w").pack(anchor="w") - ctk.CTkEntry(col_frame, width=w_, height=30, font=(FONT_FAMILY, 12), - corner_radius=8, fg_color=FIELD_BG, - border_color=FIELD_BORDER, border_width=1, - text_color=TEXT_PRIMARY, - textvariable=ctk.StringVar( - value=str(cfg.get(key, DEFAULT_CONFIG[key])) - )).pack(anchor="w") - - _adv_entries = list(adv_frame.winfo_children()) - _adv_keys = ["buf_kb", "pool_size", "log_max_mb"] - - autostart_var = None - if _supports_autostart(): - autostart_var = ctk.BooleanVar(value=cfg["autostart"]) - ctk.CTkCheckBox(frame, text="Автозапуск ΠΏΡ€ΠΈ Π²ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΠΈ Windows", - variable=autostart_var, font=(FONT_FAMILY, 13), - text_color=TEXT_PRIMARY, - fg_color=TG_BLUE, hover_color=TG_BLUE_HOVER, - corner_radius=6, border_width=2, - border_color=FIELD_BORDER).pack(anchor="w", pady=(0, 8)) - ctk.CTkLabel(frame, text="ΠŸΡ€ΠΈ ΠΏΠ΅Ρ€Π΅ΠΌΠ΅Ρ‰Π΅Π½ΠΈΠΈ Ρ„Π°ΠΉΠ»Π° ΠΈΠ»ΠΈ ΠΎΡ‚ΠΊΡ€Ρ‹Ρ‚ΠΈΠΈ ΠΈΠ· Π΄Ρ€ΡƒΠ³ΠΎΠΉ ΠΏΠ°ΠΏΠΊΠΈ\nавтозапуск Π±ΡƒΠ΄Π΅Ρ‚ ΡΠ±Ρ€ΠΎΡˆΠ΅Π½", - font=(FONT_FAMILY, 13), text_color=TEXT_SECONDARY, - anchor="w", justify="left").pack(anchor="w", pady=(0, 8)) - - def on_save(): - import socket as _sock - host_val = host_var.get().strip() - try: - _sock.inet_aton(host_val) - except OSError: - _show_error("НСкоррСктный IP-адрСс.") - return - - try: - port_val = int(port_var.get().strip()) - if not (1 <= port_val <= 65535): - raise ValueError - except ValueError: - _show_error("ΠŸΠΎΡ€Ρ‚ Π΄ΠΎΠ»ΠΆΠ΅Π½ Π±Ρ‹Ρ‚ΡŒ числом 1-65535") - return - - lines = [l.strip() for l in dc_textbox.get("1.0", "end").strip().splitlines() - if l.strip()] - try: - tg_ws_proxy.parse_dc_ip_list(lines) - except ValueError as e: - _show_error(str(e)) - return - - new_cfg = { - "host": host_val, - "port": port_val, - "dc_ip": lines, - "verbose": verbose_var.get(), - "autostart": (autostart_var.get() if autostart_var is not None else False), - } - - for i, key in enumerate(_adv_keys): - col_frame = _adv_entries[i] - entry = col_frame.winfo_children()[1] - try: - val = float(entry.get().strip()) - if key in ("buf_kb", "pool_size"): - val = int(val) - new_cfg[key] = val - except ValueError: - new_cfg[key] = DEFAULT_CONFIG[key] - save_config(new_cfg) - _config.update(new_cfg) - log.info("Config saved: %s", new_cfg) - - if _supports_autostart(): - set_autostart_enabled(bool(new_cfg.get("autostart", False))) - - _tray_icon.menu = _build_menu() - - from tkinter import messagebox - if messagebox.askyesno("ΠŸΠ΅Ρ€Π΅Π·Π°ΠΏΡƒΡΡ‚ΠΈΡ‚ΡŒ?", - "Настройки сохранСны.\n\n" - "ΠŸΠ΅Ρ€Π΅Π·Π°ΠΏΡƒΡΡ‚ΠΈΡ‚ΡŒ прокси сСйчас?", - parent=root): - root.destroy() - restart_proxy() - else: - root.destroy() - - def on_cancel(): - root.destroy() - - btn_frame = ctk.CTkFrame(frame, fg_color="transparent") - btn_frame.pack(fill="x", pady=(20, 0)) - ctk.CTkButton(btn_frame, text="Π‘ΠΎΡ…Ρ€Π°Π½ΠΈΡ‚ΡŒ", height=38, - font=(FONT_FAMILY, 14, "bold"), corner_radius=10, - fg_color=TG_BLUE, hover_color=TG_BLUE_HOVER, - text_color="#ffffff", - command=on_save).pack(side="left", fill="x", expand=True, padx=(0, 8)) - ctk.CTkButton(btn_frame, text="ΠžΡ‚ΠΌΠ΅Π½Π°", height=38, - font=(FONT_FAMILY, 14), corner_radius=10, - fg_color=FIELD_BG, hover_color=FIELD_BORDER, - text_color=TEXT_PRIMARY, border_width=1, - border_color=FIELD_BORDER, - command=on_cancel).pack(side="right", fill="x", expand=True) - - root.mainloop() - - -def _on_open_logs(icon=None, item=None): - log.info("Opening log file: %s", LOG_FILE) - if LOG_FILE.exists(): - os.startfile(str(LOG_FILE)) - else: - _show_info("Π€Π°ΠΉΠ» Π»ΠΎΠ³ΠΎΠ² Π΅Ρ‰Ρ‘ Π½Π΅ создан.", "TG WS Proxy") - - -def _on_exit(icon=None, item=None): - global _exiting - if _exiting: - os._exit(0) - return - _exiting = True - log.info("User requested exit") - - def _force_exit(): - time.sleep(3) - os._exit(0) - threading.Thread(target=_force_exit, daemon=True, name="force-exit").start() - - if icon: - icon.stop() - - - -def _show_first_run(): - _ensure_dirs() - if FIRST_RUN_MARKER.exists(): - return - - host = _config.get("host", DEFAULT_CONFIG["host"]) - port = _config.get("port", DEFAULT_CONFIG["port"]) - tg_url = f"tg://socks?server={host}&port={port}" - - if ctk is None: - FIRST_RUN_MARKER.touch() - return - - ctk.set_appearance_mode("light") - ctk.set_default_color_theme("blue") - - TG_BLUE = "#3390ec" - TG_BLUE_HOVER = "#2b7cd4" - BG = "#ffffff" - FIELD_BG = "#f0f2f5" - FIELD_BORDER = "#d6d9dc" - TEXT_PRIMARY = "#000000" - TEXT_SECONDARY = "#707579" - FONT_FAMILY = "Segoe UI" - - root = ctk.CTk() - root.title("TG WS Proxy") - root.resizable(False, False) - root.attributes("-topmost", True) - icon_path = str(Path(__file__).parent / "icon.ico") - root.iconbitmap(icon_path) - - w, h = 520, 440 - sw = root.winfo_screenwidth() - sh = root.winfo_screenheight() - root.geometry(f"{w}x{h}+{(sw-w)//2}+{(sh-h)//2}") - root.configure(fg_color=BG) - - frame = ctk.CTkFrame(root, fg_color=BG, corner_radius=0) - frame.pack(fill="both", expand=True, padx=28, pady=24) - - title_frame = ctk.CTkFrame(frame, fg_color="transparent") - title_frame.pack(anchor="w", pady=(0, 16), fill="x") - - # Blue accent bar - accent_bar = ctk.CTkFrame(title_frame, fg_color=TG_BLUE, - width=4, height=32, corner_radius=2) - accent_bar.pack(side="left", padx=(0, 12)) - - ctk.CTkLabel(title_frame, text="ΠŸΡ€ΠΎΠΊΡΠΈ Π·Π°ΠΏΡƒΡ‰Π΅Π½ ΠΈ Ρ€Π°Π±ΠΎΡ‚Π°Π΅Ρ‚ Π² систСмном Ρ‚Ρ€Π΅Π΅", - font=(FONT_FAMILY, 17, "bold"), - text_color=TEXT_PRIMARY).pack(side="left") - - # Info sections - sections = [ - ("Как ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡ΠΈΡ‚ΡŒ Telegram Desktop:", True), - (" АвтоматичСски:", True), - (f" ПКМ ΠΏΠΎ ΠΈΠΊΠΎΠ½ΠΊΠ΅ Π² Ρ‚Ρ€Π΅Π΅ β†’ Β«ΠžΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ Π² TelegramΒ»", False), - (f" Или ссылка: {tg_url}", False), - ("\n Π’Ρ€ΡƒΡ‡Π½ΡƒΡŽ:", True), - (" Настройки β†’ ΠŸΡ€ΠΎΠ΄Π²ΠΈΠ½ΡƒΡ‚Ρ‹Π΅ β†’ Π’ΠΈΠΏ ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΡ β†’ ΠŸΡ€ΠΎΠΊΡΠΈ", False), - (f" SOCKS5 β†’ {host} : {port} (Π±Π΅Π· Π»ΠΎΠ³ΠΈΠ½Π°/пароля)", False), - ] - - for text, bold in sections: - weight = "bold" if bold else "normal" - ctk.CTkLabel(frame, text=text, - font=(FONT_FAMILY, 13, weight), - text_color=TEXT_PRIMARY, - anchor="w", justify="left").pack(anchor="w", pady=1) - - # Spacer - ctk.CTkFrame(frame, fg_color="transparent", height=16).pack() - - # Separator - ctk.CTkFrame(frame, fg_color=FIELD_BORDER, height=1, - corner_radius=0).pack(fill="x", pady=(0, 12)) - - # Checkbox - auto_var = ctk.BooleanVar(value=True) - ctk.CTkCheckBox(frame, text="ΠžΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ прокси Π² Telegram сСйчас", - variable=auto_var, font=(FONT_FAMILY, 13), - text_color=TEXT_PRIMARY, - fg_color=TG_BLUE, hover_color=TG_BLUE_HOVER, - corner_radius=6, border_width=2, - border_color=FIELD_BORDER).pack(anchor="w", pady=(0, 16)) - - def on_ok(): - FIRST_RUN_MARKER.touch() - open_tg = auto_var.get() - root.destroy() - if open_tg: - _on_open_in_telegram() - - ctk.CTkButton(frame, text="ΠΠ°Ρ‡Π°Ρ‚ΡŒ", width=180, height=42, - font=(FONT_FAMILY, 15, "bold"), corner_radius=10, - fg_color=TG_BLUE, hover_color=TG_BLUE_HOVER, - text_color="#ffffff", - command=on_ok).pack(pady=(0, 0)) - - root.protocol("WM_DELETE_WINDOW", on_ok) - root.mainloop() - - -def _has_ipv6_enabled() -> bool: - import socket as _sock - try: - addrs = _sock.getaddrinfo(_sock.gethostname(), None, _sock.AF_INET6) - for addr in addrs: - ip = addr[4][0] - if ip and not ip.startswith('::1') and not ip.startswith('fe80::1'): - return True - except Exception: - pass - try: - s = _sock.socket(_sock.AF_INET6, _sock.SOCK_STREAM) - s.bind(('::1', 0)) - s.close() - return True - except Exception: - return False - - -def _check_ipv6_warning(): - _ensure_dirs() - if IPV6_WARN_MARKER.exists(): - return - if not _has_ipv6_enabled(): - return - - IPV6_WARN_MARKER.touch() - - threading.Thread(target=_show_ipv6_dialog, daemon=True).start() - - -def _show_ipv6_dialog(): - _show_info( - "На вашСм ΠΊΠΎΠΌΠΏΡŒΡŽΡ‚Π΅Ρ€Π΅ Π²ΠΊΠ»ΡŽΡ‡Π΅Π½Π° ΠΏΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΊΠ° ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΡ ΠΏΠΎ IPv6.\n\n" - "Telegram ΠΌΠΎΠΆΠ΅Ρ‚ ΠΏΡ‹Ρ‚Π°Ρ‚ΡŒΡΡ ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π°Ρ‚ΡŒΡΡ Ρ‡Π΅Ρ€Π΅Π· IPv6, " - "Ρ‡Ρ‚ΠΎ Π½Π΅ поддСрТиваСтся ΠΈ ΠΌΠΎΠΆΠ΅Ρ‚ привСсти ΠΊ ошибкам.\n\n" - "Если прокси Π½Π΅ Ρ€Π°Π±ΠΎΡ‚Π°Π΅Ρ‚ ΠΈΠ»ΠΈ Π² Π»ΠΎΠ³Π°Ρ… ΠΏΡ€ΠΈΡΡƒΡ‚ΡΡ‚Π²ΡƒΡŽΡ‚ ошибки, " - "связанныС с ΠΏΠΎΠΏΡ‹Ρ‚ΠΊΠ°ΠΌΠΈ ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΡ ΠΏΠΎ IPv6 - " - "ΠΏΠΎΠΏΡ€ΠΎΠ±ΡƒΠΉΡ‚Π΅ ΠΎΡ‚ΠΊΠ»ΡŽΡ‡ΠΈΡ‚ΡŒ Π² настройках прокси Telegram ΠΏΠΎΠΏΡ‹Ρ‚ΠΊΡƒ соСдинСния " - "ΠΏΠΎ IPv6. Если данная ΠΌΠ΅Ρ€Π° Π½Π΅ ΠΏΠΎΠΌΠΎΠ³Π°Π΅Ρ‚, ΠΏΠΎΠΏΡ€ΠΎΠ±ΡƒΠΉΡ‚Π΅ ΠΎΡ‚ΠΊΠ»ΡŽΡ‡ΠΈΡ‚ΡŒ IPv6 " - "Π² систСмС.\n\n" - "Π­Ρ‚ΠΎ ΠΏΡ€Π΅Π΄ΡƒΠΏΡ€Π΅ΠΆΠ΄Π΅Π½ΠΈΠ΅ Π±ΡƒΠ΄Π΅Ρ‚ ΠΏΠΎΠΊΠ°Π·Π°Π½ΠΎ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ ΠΎΠ΄ΠΈΠ½ Ρ€Π°Π·.", - "TG WS Proxy") - - -def _build_menu(): - if pystray is None: - return None - host = _config.get("host", DEFAULT_CONFIG["host"]) - port = _config.get("port", DEFAULT_CONFIG["port"]) - return pystray.Menu( - pystray.MenuItem( - f"ΠžΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ Π² Telegram ({host}:{port})", - _on_open_in_telegram, - default=True), - pystray.Menu.SEPARATOR, - pystray.MenuItem("ΠŸΠ΅Ρ€Π΅Π·Π°ΠΏΡƒΡΡ‚ΠΈΡ‚ΡŒ прокси", _on_restart), - pystray.MenuItem("Настройки...", _on_edit_config), - pystray.MenuItem("ΠžΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ Π»ΠΎΠ³ΠΈ", _on_open_logs), - pystray.Menu.SEPARATOR, - pystray.MenuItem("Π’Ρ‹Ρ…ΠΎΠ΄", _on_exit), - ) - - -def run_tray(): - global _tray_icon, _config - - _config = load_config() - save_config(_config) - - if LOG_FILE.exists(): - try: - LOG_FILE.unlink() - except Exception: - pass - - setup_logging(_config.get("verbose", False), - log_max_mb=_config.get("log_max_mb", DEFAULT_CONFIG["log_max_mb"])) - log.info("TG WS Proxy tray app starting") - log.info("Config: %s", _config) - log.info("Log file: %s", LOG_FILE) - - if pystray is None or Image is None: - log.error("pystray or Pillow not installed; " - "running in console mode") - start_proxy() - try: - while True: - time.sleep(1) - except KeyboardInterrupt: - stop_proxy() - return - - start_proxy() - - _show_first_run() - _check_ipv6_warning() - - icon_image = _load_icon() - _tray_icon = pystray.Icon( - APP_NAME, - icon_image, - "TG WS Proxy", - menu=_build_menu()) - - log.info("Tray icon running") - _tray_icon.run() - - stop_proxy() - log.info("Tray app exited") - - -def main(): - if not _acquire_lock(): - _show_info("ΠŸΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ ΡƒΠΆΠ΅ Π·Π°ΠΏΡƒΡ‰Π΅Π½ΠΎ.", os.path.basename(sys.argv[0])) - return - - try: - run_tray() - finally: - _release_lock() - - -if __name__ == "__main__": - main()