diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml deleted file mode 100644 index bef67fb4..00000000 --- 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 a44eb7db..00000000 --- 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 a44043bb..c549c98b 100644 --- a/README.md +++ b/README.md @@ -1,206 +1,90 @@ -> [!CAUTION] -> -> ### Реакция антивирусов -> -> Windows Defender часто ошибочно помечает приложение как **Wacatac**. -> Если вы не можете скачать из-за блокировки, то: -> -> 1) Попробуйте скачать версию win7 (она ничем не отличается в плане функционала) -> 2) Отключите антивирус на время скачивания, добавьте файл в исключения и включите обратно -> -> **Всегда проверяйте, что скачиваете из интернета, тем более из непроверенных источников. Всегда лучше смотреть на детекты широко известных антивирусов на VirusTotal** - -# TG WS Proxy - -**Локальный SOCKS5-прокси** для Telegram Desktop, который **ускоряет работу Telegram**, перенаправляя трафик через WebSocket-соединения. Данные передаются в том же зашифрованном виде, а для работы не нужны сторонние сервера. - -image - -## Как это работает - -``` -Telegram Desktop → 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-соединение +
+ + # Telegram WS Proxy Android +
+ Android SDK + Go Version + Kotlin + + Stars + +
+
-## 🚀 Быстрый старт +**TG WS Proxy Android** — это локальный **MTProto-прокси** для Telegram на Android. Приложение помогает частично решать проблемы и в ряде сценариев ускоряет работу мессенджера, перенаправляя трафик через защищённые CloudFlare WebSocket-соединения или напрямую к датацентрам Telegram. -### Windows +--- -Перейдите на [страницу релизов](https://github.com/Flowseal/tg-ws-proxy/releases) и скачайте **`TgWsProxy_windows.exe`**. Он собирается автоматически через [Github Actions](https://github.com/Flowseal/tg-ws-proxy/actions) из открытого исходного кода. +MyCollages (5) -При первом запуске откроется окно с инструкцией по подключению Telegram Desktop. Приложение сворачивается в системный трей. +## Возможности Android-версии -**Меню трея:** +- **Современный UI/UX:** приложение полностью адаптировано под актуальный Android-интерфейс на базе Material 3 и Jetpack Compose. Основные действия доступны быстро и без перегруженных экранов. +- **Интеграция с Telegram:** кнопка **«Применить в Telegram»** автоматически передаёт прокси в совместимые клиенты через `tg://proxy` (AyuGram, Plus Messenger, NekoGram и другие). +- **Фоновый режим:** используется `Foreground Service`, уведомление о работе сервиса и дополнительная логика удержания соединения, чтобы Android не выгружал прокси слишком агрессивно. +- **Лог-вьюер:** встроенный просмотр событий в реальном времени помогает быстро понять, что происходит с подключением, маршрутом и пулом соединений. +- **Темы и палитры:** поддерживаются Dynamic Colors на Android 12+, а также встроенные палитры для более старых устройств. +- **Авто-обновления внутри приложения:** вручную проверять релизы больше не нужно — когда выйдет новая версия, приложение само покажет уведомление об обновлении. +- **Раздел «Информация»:** внутри приложения есть расширенная справка по настройкам, особенностям CloudFlare, пулу WS-соединений и ручной конфигурации датацентров. -- **Открыть в 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 -``` +## Что нового в версии 1.1.9 -При первом запуске откроется окно с инструкцией. Приложение работает в системном трее (требуется AppIndicator). +* **Багфиксы и стабильность:** Улучшена стабильность ядра, исправлены ошибки автообновлений и работы в Direct-режиме. Удалён DataSync для предотвращения крашей на новых Android. +* **Редизайн:** Слегка обновлена тёмная тема (добавлены орбы на фон), раздел «Инфо» стал информативнее. +* **Автозапуск и быстрый доступ:** Добавлена опция автозапуска при загрузке системы и удобный тайл "T" в шторку уведомлений. +* **Совместимость:** Сборка armeabi-v7a теперь использует Go 1.19 для лучшей работы на старых устройствах. +* **CloudFlare CDN:** Улучшена логика работы с CF-доменами для стабильного подключения. -## Установка из исходников +--- -### Консольный 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 +```text +Telegram Android → Локальный MTProto (по умолчанию 127.0.0.1:1443) → TG WS Proxy → WSS (через CloudFlare или напрямую) → Telegram DC ``` -## CLI-скрипты (pyproject.toml) - -CLI команды объявляются в `pyproject.toml` в секции `[project.scripts]` и должны указывать на `module:function`. +1. Приложение поднимает локальный MTProto-прокси средствами нативного движка на языке **Go**. +2. Перехватывает подключения Telegram через локальный порт и сгенерированный секретный ключ. +3. Извлекает `DC ID` из исходного пакета и устанавливает защищённое WebSocket (`TLS`) соединение с нужным датацентром, при необходимости проксируя трафик через CloudFlare. +4. Использует пул соединений, keepalive-механику и fallback-сценарии для более устойчивой работы в реальных сетевых условиях. -Пример: +## Быстрый старт -```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" -``` +1. Скачайте актуальный `APK` со **[страницы релизов](https://github.com/amurcanov/tg-ws-proxy-android/releases)**. +2. Установите приложение на ваш Android-смартфон. +3. Откройте **TG WS Proxy Android**. +4. Ознакомьтесь со справкой внутри приложения. +5. Нажмите **«Запустить прокси»** — появится уведомление о работе в фоновом режиме. +6. Нажмите **«Применить в Telegram»** — откроется Telegram-клиент, где останется только подтвердить подключение. -## Настройка Telegram Desktop +--- -### Автоматически +# 🎦 Видео гайд по установке и использованию -ПКМ по иконке в трее → **«Открыть в Telegram»** +
-### Вручную +578516258-6b2df494-de8d-44a2-a281-389fc7551a7c -1. Telegram → **Настройки** → **Продвинутые настройки** → **Тип подключения** → **Прокси** -2. Добавить прокси: - - **Тип:** SOCKS5 - - **Сервер:** `127.0.0.1` - - **Порт:** `1080` - - **Логин/Пароль:** оставить пустыми +

-## Конфигурация +[**Смотреть на YouTube**](https://youtu.be/RP4RwyEHpwc) | [**Смотреть на VK Video**](https://vkvideo.ru/video-234234162_456239074) | [**Смотреть в Telegram**](https://t.me/avencoreschat/506796) -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 -} -``` -## Автоматическая сборка +* **Краши и проблемы с установкой:** если у вас возникают сбои, вылеты или ошибки при установке, пожалуйста, сохраняйте отчёты и ссылки на них. Также ознакомьтесь с блоком `NOTE` ниже и поднимайте полноценные `issue` с полезной технической информацией. -Проект содержит спецификации 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)) для автоматической сборки. -Минимально поддерживаемые версии ОС для текущих бинарных сборок: +> [!NOTE] +> ### Отчёты об ошибках +> Приложение адаптировано под мобильные сети, однако проблемы с фоновой работой всё ещё возможны из-за системных ограничений или сети. +> +> Если у вас возникла проблема, сбой или вопрос, пожалуйста, нажмите кнопку **«Собрать отчёт»** внутри приложения и приложите полученные данные к вашему `issue`. Мелкие ошибки в логах при нормально работающем прокси можно игнорировать. -- 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 для системного трея) +--- ## Лицензия -[MIT License](LICENSE) +Этот форк распространяется под лицензией **GPLv3**. Оригинальный код `tg-ws-proxy` от [Flowseal](https://github.com/Flowseal) доступен под лицензией **MIT**. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 00000000..dff07871 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,134 @@ +import java.util.Properties + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "com.amurcanov.tgwsproxy" + compileSdk = 35 + + defaultConfig { + applicationId = "com.amurcanov.tgwsproxy" + minSdk = 24 + targetSdk = 35 + versionCode = 19 + versionName = "1.1.9" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + + ndk { + abiFilters.addAll(listOf("arm64-v8a", "armeabi-v7a", "x86_64")) + } + } + + // ABI splits: produce separate APKs per architecture + universal + splits { + abi { + isEnable = true + reset() + include("arm64-v8a", "armeabi-v7a", "x86_64") + isUniversalApk = true // Universal APK with all 3 architectures + } + } + + val localProperties = Properties() + val localPropertiesFile = rootProject.file("local.properties") + if (localPropertiesFile.exists()) { + localProperties.load(localPropertiesFile.inputStream()) + } + + signingConfigs { + create("release") { + val keyFile = localProperties.getProperty("KEYSTORE_FILE") + if (keyFile != null) { + // Резолвим путь: если начинается с "..", берём от корня проекта + val resolvedFile = if (keyFile.startsWith("..")) { + // ../release.keystore -> корень проекта / release.keystore + file(rootDir.resolve(keyFile.substring(3))) + } else { + file(keyFile) + } + if (resolvedFile.exists()) { + storeFile = resolvedFile + storePassword = localProperties.getProperty("KEYSTORE_PASSWORD") + keyAlias = localProperties.getProperty("KEY_ALIAS") + keyPassword = localProperties.getProperty("KEY_PASSWORD") + } else { + println("WARNING: Keystore file not found: $keyFile (resolved: ${resolvedFile.absolutePath})") + } + } + enableV1Signing = true + enableV2Signing = true + enableV3Signing = true + } + } + + buildTypes { + getByName("release") { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + val keyFile = localProperties.getProperty("KEYSTORE_FILE") + val resolvedFile = if (keyFile != null && keyFile.startsWith("..")) { + file(rootDir.resolve(keyFile.substring(3))) + } else if (keyFile != null) { + file(keyFile) + } else null + + if (resolvedFile != null && resolvedFile.exists()) { + signingConfig = signingConfigs.getByName("release") + println("✅ Signing config applied: ${resolvedFile.absolutePath}") + } else { + println("⚠️ WARNING: Keystore not found, using debug signing") + println(" Looked for: ${resolvedFile?.absolutePath ?: keyFile}") + } + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + buildFeatures { + compose = true + buildConfig = true + } + // composeOptions removed — AGP 9.x handles Compose compiler internally + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + sourceSets { + getByName("main") { + jniLibs.setSrcDirs(listOf("src/main/jniLibs")) + } + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.15.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") + implementation("androidx.activity:activity-compose:1.9.3") + implementation(platform("androidx.compose:compose-bom:2024.12.01")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + + // DataStore for persistent settings + implementation("androidx.datastore:datastore-preferences:1.1.1") + + // 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 00000000..29689da2 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,48 @@ +# Add project specific ProGuard rules here. + +# ─── JNA ─── +-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 { *; } +-keepclassmembers class * implements com.sun.jna.Library { + ; +} +# JNA callback & structure support +-keep class * implements com.sun.jna.Callback { *; } +-keep class * extends com.sun.jna.Structure { *; } + +# ─── 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 { + ; +} + +# ─── ProxyService (foreground service, must not be obfuscated) ─── +-keep class com.amurcanov.tgwsproxy.ProxyService { *; } + +# ─── DataStore ─── +-keep class androidx.datastore.** { *; } +-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite { + ; +} + +# ─── Coroutines ─── +-dontwarn kotlinx.coroutines.** +-keep class kotlinx.coroutines.** { *; } +-keepclassmembers class kotlinx.coroutines.** { *; } + +# ─── Compose ─── +-dontwarn androidx.compose.** +-keep class androidx.compose.runtime.** { *; } +-keep class androidx.compose.ui.** { *; } +-keep @androidx.compose.runtime.Composable class * { *; } + +# ─── Keep native .so loaders ─── +-keepclasseswithmembernames class * { + native ; +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..92b77b77 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/AppUpdate.kt b/app/src/main/java/com/amurcanov/tgwsproxy/AppUpdate.kt new file mode 100644 index 00000000..7e15a812 --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/AppUpdate.kt @@ -0,0 +1,343 @@ +package com.amurcanov.tgwsproxy + +import android.content.Context +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL + +const val UPDATE_CHECK_NEVER = -1 +const val DEFAULT_UPDATE_CHECK_INTERVAL_HOURS = 12 +private const val UPDATE_LOG_TAG = "TgWsProxy" +private const val GITHUB_RELEASES_URL = "https://api.github.com/repos/amurcanov/tg-ws-proxy-android/releases?per_page=30" +private const val GITHUB_LATEST_RELEASE_URL = "https://api.github.com/repos/amurcanov/tg-ws-proxy-android/releases/latest" +private const val GITHUB_LATEST_RELEASE_WEB_URL = "https://github.com/amurcanov/tg-ws-proxy-android/releases/latest" +private const val GITHUB_RELEASE_TAG_URL_PREFIX = "https://github.com/amurcanov/tg-ws-proxy-android/releases/tag/" +private const val GITHUB_TAGS_URL = "https://api.github.com/repos/amurcanov/tg-ws-proxy-android/tags?per_page=100" +private const val GITHUB_TAG_TREE_URL_PREFIX = "https://github.com/amurcanov/tg-ws-proxy-android/tree/" +private const val GITHUB_API_RATE_LIMIT_FALLBACK_MS = 30L * 60L * 1000L +private val VERSION_NUMBER_REGEX = Regex("\\d+(?:\\.\\d+)*") +@Volatile +private var githubApiCooldownUntilMs = 0L + +fun updateIntervalHoursToMillis(hours: Int): Long? = when { + hours <= 0 -> null + else -> hours * 60L * 60L * 1000L +} + +fun updateIntervalLabel(context: Context, hours: Int): String = when (hours) { + 7 -> context.getString(R.string.interval_7h) + 24 -> context.getString(R.string.interval_24h) + 48 -> context.getString(R.string.interval_48h) + UPDATE_CHECK_NEVER -> context.getString(R.string.interval_never) + else -> context.getString(R.string.interval_hours, hours) +} + +fun updateIntervalLabel(hours: Int): String = when (hours) { + 7 -> "7 h" + 24 -> "24 h" + 48 -> "48 h" + UPDATE_CHECK_NEVER -> "Never" + else -> "$hours h" +} + +data class AppReleaseInfo( + val versionTag: String, + val releaseUrl: String, + val source: RemoteVersionSource +) + +enum class RemoteVersionSource { + Release, + Tag +} + +const val UPDATE_DIALOG_ACTION_POSTPONED = "postponed" +const val UPDATE_DIALOG_ACTION_UPDATE = "update" + +suspend fun fetchLatestReleaseInfo(localVersion: String? = null): AppReleaseInfo? = withContext(Dispatchers.IO) { + val latestRelease = fetchReleaseFromLatestWebRedirect() + ?: fetchReleaseFromLatestEndpoint() + ?: fetchLatestStableReleaseFromList() + val latestTag = fetchLatestTagFromList() + + when { + latestRelease == null -> latestTag + latestTag == null -> latestRelease + isNewerVersion(latestRelease.versionTag, latestTag.versionTag) -> latestTag + else -> latestRelease + } +} + +fun isNewerVersion(local: String, remote: String): Boolean { + val localParts = versionParts(local) + val remoteParts = versionParts(remote) + if (remoteParts.isEmpty()) return false + + val maxLen = maxOf(localParts.size, remoteParts.size) + + for (i in 0 until maxLen) { + val l = localParts.getOrElse(i) { 0 } + val r = remoteParts.getOrElse(i) { 0 } + if (r > l) return true + if (r < l) return false + } + + return false +} + +private fun fetchLatestStableReleaseFromList(): AppReleaseInfo? { + val response = fetchGitHubApi(GITHUB_RELEASES_URL) ?: return null + val releases = try { + JSONArray(response) + } catch (e: Exception) { + Log.w(UPDATE_LOG_TAG, "[WARN] Update check: failed to parse releases list", e) + return null + } + + var bestRelease: AppReleaseInfo? = null + for (i in 0 until releases.length()) { + val json = releases.optJSONObject(i) ?: continue + if (json.optBoolean("draft") || json.optBoolean("prerelease")) continue + + val release = json.toAppReleaseInfo() ?: continue + if (bestRelease == null || isNewerVersion(bestRelease.versionTag, release.versionTag)) { + bestRelease = release + } + } + + return bestRelease +} + +private fun fetchLatestTagFromList(): AppReleaseInfo? { + val response = fetchGitHubApi(GITHUB_TAGS_URL) ?: return null + val tags = try { + JSONArray(response) + } catch (e: Exception) { + Log.w(UPDATE_LOG_TAG, "[WARN] Update check: failed to parse tags list", e) + return null + } + + var bestTag: AppReleaseInfo? = null + for (i in 0 until tags.length()) { + val json = tags.optJSONObject(i) ?: continue + val tagName = normalizeVersionTag(json.optString("name")) + if (tagName.isBlank()) continue + + val tag = AppReleaseInfo( + versionTag = tagName, + releaseUrl = "$GITHUB_TAG_TREE_URL_PREFIX$tagName", + source = RemoteVersionSource.Tag + ) + if (bestTag == null || isNewerVersion(bestTag.versionTag, tag.versionTag)) { + bestTag = tag + } + } + + return bestTag +} + +private fun fetchReleaseFromLatestEndpoint(): AppReleaseInfo? { + val response = fetchGitHubApi(GITHUB_LATEST_RELEASE_URL) ?: return null + val json = try { + JSONObject(response) + } catch (e: Exception) { + Log.w(UPDATE_LOG_TAG, "[WARN] Update check: failed to parse latest release", e) + return null + } + + return json.toAppReleaseInfo() +} + +private fun fetchReleaseFromLatestWebRedirect(): AppReleaseInfo? { + var conn: HttpURLConnection? = null + return try { + conn = URL(GITHUB_LATEST_RELEASE_WEB_URL).openConnection() as HttpURLConnection + applyNoCacheHeaders(conn) + conn.instanceFollowRedirects = false + conn.requestMethod = "GET" + conn.setRequestProperty("Accept", "text/html,*/*") + conn.setRequestProperty("User-Agent", "TGWSProxyAndroid/${BuildConfig.VERSION_NAME}") + conn.connectTimeout = 8_000 + conn.readTimeout = 8_000 + + val responseCode = conn.responseCode + val location = conn.getHeaderField("Location") + if (!location.isNullOrBlank()) { + val releaseUrl = URL(URL(GITHUB_LATEST_RELEASE_WEB_URL), location).toString() + val versionTag = extractTagFromReleaseUrl(releaseUrl) + if (!versionTag.isNullOrBlank()) { + return AppReleaseInfo( + versionTag = versionTag, + releaseUrl = releaseUrl, + source = RemoteVersionSource.Release + ) + } + } + + if (responseCode in 200..299) { + val response = conn.inputStream.bufferedReader().use { it.readText() } + val versionTag = Regex("/releases/tag/([^\"?#<]+)").find(response)?.groupValues?.getOrNull(1) + if (!versionTag.isNullOrBlank()) { + return AppReleaseInfo( + versionTag = versionTag, + releaseUrl = "$GITHUB_RELEASE_TAG_URL_PREFIX$versionTag", + source = RemoteVersionSource.Release + ) + } + } + + Log.w(UPDATE_LOG_TAG, "[WARN] Update check: GitHub web fallback returned $responseCode") + null + } catch (e: Exception) { + Log.w(UPDATE_LOG_TAG, "[WARN] Update check: GitHub web fallback failed", e) + null + } finally { + conn?.disconnect() + } +} + +private fun fetchGitHubApi(url: String): String? { + val now = System.currentTimeMillis() + if (now < githubApiCooldownUntilMs) { + return null + } + + return fetchHttpText( + url = url, + sourceLabel = "GitHub API", + accept = "application/vnd.github+json", + isGitHubApi = true + ) +} + +private fun fetchHttpText( + url: String, + sourceLabel: String, + accept: String, + isGitHubApi: Boolean = false +): String? { + var conn: HttpURLConnection? = null + return try { + conn = URL(url).openConnection() as HttpURLConnection + applyNoCacheHeaders(conn) + conn.requestMethod = "GET" + conn.setRequestProperty("Accept", accept) + if (isGitHubApi) { + conn.setRequestProperty("X-GitHub-Api-Version", "2022-11-28") + } + conn.setRequestProperty("User-Agent", "TGWSProxyAndroid/${BuildConfig.VERSION_NAME}") + conn.connectTimeout = 8_000 + conn.readTimeout = 8_000 + + val responseCode = conn.responseCode + val stream = if (responseCode in 200..299) conn.inputStream else conn.errorStream + val response = stream?.bufferedReader()?.use { it.readText() }.orEmpty() + + if (responseCode in 200..299) { + if (isGitHubApi) { + githubApiCooldownUntilMs = 0L + } + response + } else { + if (isGitHubApi) { + noteGitHubApiCooldown(conn, responseCode, response) + } + Log.w( + UPDATE_LOG_TAG, + "[WARN] Update check: $sourceLabel returned $responseCode ${response.take(300)}" + ) + null + } + } catch (e: Exception) { + Log.w(UPDATE_LOG_TAG, "[WARN] Update check: $sourceLabel request failed", e) + null + } finally { + conn?.disconnect() + } +} + +private fun applyNoCacheHeaders(conn: HttpURLConnection) { + conn.useCaches = false + conn.setRequestProperty("Cache-Control", "no-cache, no-store, max-age=0") + conn.setRequestProperty("Pragma", "no-cache") + conn.setRequestProperty("Expires", "0") +} + +private fun noteGitHubApiCooldown(conn: HttpURLConnection, responseCode: Int, response: String) { + if (responseCode != HttpURLConnection.HTTP_FORBIDDEN && responseCode != 429) { + return + } + + val now = System.currentTimeMillis() + val retryAfterUntil = conn + .getHeaderField("Retry-After") + ?.trim() + ?.toLongOrNull() + ?.takeIf { it > 0L } + ?.let { now + it * 1000L } + val rateLimitResetUntil = conn + .getHeaderField("X-RateLimit-Reset") + ?.trim() + ?.toLongOrNull() + ?.takeIf { it > 0L } + ?.let { it * 1000L } + val fallbackUntil = now + if (response.contains("rate limit", ignoreCase = true)) { + GITHUB_API_RATE_LIMIT_FALLBACK_MS + } else { + 5L * 60L * 1000L + } + + val cooldownUntil = listOfNotNull(retryAfterUntil, rateLimitResetUntil) + .filter { it > now } + .minOrNull() + ?: fallbackUntil + + if (cooldownUntil > githubApiCooldownUntilMs) { + githubApiCooldownUntilMs = cooldownUntil + Log.w( + UPDATE_LOG_TAG, + "[WARN] Update check: GitHub API cooldown ${(cooldownUntil - now) / 1000}s after HTTP $responseCode" + ) + } +} + +private fun JSONObject.toAppReleaseInfo(): AppReleaseInfo? { + val versionTag = normalizeVersionTag(optString("tag_name")) + val releaseUrl = optString("html_url").trim() + if (versionTag.isBlank() || releaseUrl.isBlank()) return null + + return AppReleaseInfo( + versionTag = versionTag, + releaseUrl = releaseUrl, + source = RemoteVersionSource.Release + ) +} + +private fun versionParts(version: String): List { + val normalized = VERSION_NUMBER_REGEX.find(version.trim())?.value ?: return emptyList() + return normalized.split(".").mapNotNull { it.toIntOrNull() } +} + +private fun normalizeVersionTag(version: String): String { + val trimmed = version.trim() + if (trimmed.isBlank()) return "" + return if (trimmed.startsWith("v", ignoreCase = true)) trimmed else "v$trimmed" +} + +private fun extractTagFromReleaseUrl(releaseUrl: String): String? { + val marker = "/releases/tag/" + val index = releaseUrl.indexOf(marker) + if (index < 0) return null + + return releaseUrl + .substring(index + marker.length) + .substringBefore("?") + .substringBefore("#") + .substringBefore("/") + .takeIf { it.isNotBlank() } + ?.let(::normalizeVersionTag) +} diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/BootReceiver.kt b/app/src/main/java/com/amurcanov/tgwsproxy/BootReceiver.kt new file mode 100644 index 00000000..b260d1b4 --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/BootReceiver.kt @@ -0,0 +1,50 @@ +package com.amurcanov.tgwsproxy + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +/** + * Receives boot broadcasts and starts the proxy when autostart is enabled. + */ +class BootReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != Intent.ACTION_BOOT_COMPLETED && + intent.action != "android.intent.action.QUICKBOOT_POWERON") { + return + } + + val pendingResult = goAsync() + val appContext = context.applicationContext + + CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { + try { + val settingsStore = SettingsStore(appContext) + if (!settingsStore.autoStartOnBoot.first()) { + Log.i(TAG, "Boot completed, autostart disabled") + return@launch + } + + val started = ProxyController.startFromSavedSettings( + context = appContext, + showInvalidPortToast = false + ) + Log.i(TAG, "Boot completed, proxy autostart requested: $started") + } catch (e: Exception) { + Log.w(TAG, "Failed to autostart proxy after boot", e) + } finally { + pendingResult.finish() + } + } + } + + companion object { + private const val TAG = "BootReceiver" + } +} diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/LogEntry.kt b/app/src/main/java/com/amurcanov/tgwsproxy/LogEntry.kt new file mode 100644 index 00000000..01164685 --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/LogEntry.kt @@ -0,0 +1,17 @@ +package com.amurcanov.tgwsproxy + +import androidx.compose.runtime.Immutable + +/** + * Immutable data class for log entries — ensures Compose skips recomposition + * when the reference hasn't changed. + */ +@Immutable +data class LogEntry( + val key: String, + val message: String, + val count: Int, + val isError: Boolean, + val priority: Int, // 3=DEBUG, 4=INFO, 5=WARN, 6=ERROR + val isEssential: Boolean = false +) diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/MainActivity.kt b/app/src/main/java/com/amurcanov/tgwsproxy/MainActivity.kt new file mode 100644 index 00000000..875c7b27 --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/MainActivity.kt @@ -0,0 +1,756 @@ +package com.amurcanov.tgwsproxy + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.PowerManager +import android.provider.Settings +import android.util.Log +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.Animatable +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.shape.GenericShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.input.pointer.pointerInput + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.PowerSettingsNew +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Terminal +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.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.luminance + +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.amurcanov.tgwsproxy.ui.AppUpdateDialog +import com.amurcanov.tgwsproxy.ui.ConnectionTab +import com.amurcanov.tgwsproxy.ui.FloatingToolbar +import com.amurcanov.tgwsproxy.ui.InfoTab +import com.amurcanov.tgwsproxy.ui.LogsTab +import com.amurcanov.tgwsproxy.ui.SettingsTab +import com.amurcanov.tgwsproxy.ui.TgWsProxyTheme +import com.amurcanov.tgwsproxy.ui.openUrlInBrowser +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.BUFFERED +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.io.BufferedReader +import java.io.InputStreamReader +import java.util.concurrent.atomic.AtomicLong +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.min +import kotlin.math.sin + +class MainActivity : ComponentActivity() { + + private val requestPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) {} + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + checkBatteryOptimizations() + + androidx.core.view.WindowCompat.setDecorFitsSystemWindows(window, false) + + setContent { + val context = LocalContext.current + val settingsStore = remember { SettingsStore(context) } + val themeMode by settingsStore.themeMode + .collectAsStateWithLifecycle(initialValue = "system") + val isDynamicColor by settingsStore.isDynamicColor + .collectAsStateWithLifecycle(initialValue = true) + val themePalette by settingsStore.themePalette + .collectAsStateWithLifecycle(initialValue = "indigo") + val scope = rememberCoroutineScope() + + LaunchedEffect(settingsStore) { + settingsStore.migrateLegacyDefaults() + } + + TgWsProxyTheme(themeMode = themeMode, dynamicColor = isDynamicColor, themePalette = themePalette) { + androidx.compose.runtime.CompositionLocalProvider( + androidx.compose.ui.platform.LocalDensity provides androidx.compose.ui.unit.Density( + density = androidx.compose.ui.platform.LocalDensity.current.density, + fontScale = 1f + ) + ) { + Box(modifier = Modifier.fillMaxSize()) { + AppBackdrop(modifier = Modifier.matchParentSize()) + + Surface( + modifier = Modifier.fillMaxSize(), + color = Color.Transparent + ) { + Box { + MainContent(settingsStore) + + FloatingToolbar( + currentTheme = themeMode, + onThemeChange = { mode -> + scope.launch { settingsStore.saveThemeMode(mode) } + }, + isDynamicColor = isDynamicColor, + onDynamicColorChange = { dc -> + scope.launch { settingsStore.saveDynamicColor(dc) } + }, + currentPalette = themePalette, + onPaletteChange = { pal -> + scope.launch { settingsStore.saveThemePalette(pal) } + } + ) + } + } + } + } + } + } + } + + 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 (_: Exception) { + Toast.makeText(this, getString(R.string.background_request_failed), Toast.LENGTH_SHORT).show() + } + } + } + } +} + +private data class NavItem( + val label: String, + val iconRes: androidx.compose.ui.graphics.vector.ImageVector +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainContent(settingsStore: SettingsStore) { + var selectedTab by rememberSaveable { mutableIntStateOf(0) } + var dragTargetIndex by remember { mutableIntStateOf(-1) } + var dragProgress by remember { mutableFloatStateOf(0f) } + val context = LocalContext.current + val density = LocalDensity.current + val scope = rememberCoroutineScope() + val updatePostponeUntil by settingsStore.updatePostponeUntil.collectAsStateWithLifecycle(initialValue = 0L) + val updatePostponeVersion by settingsStore.updatePostponeVersion.collectAsStateWithLifecycle(initialValue = "") + val updateCheckIntervalHours by settingsStore.updateCheckIntervalHours.collectAsStateWithLifecycle( + initialValue = UPDATE_CHECK_NEVER + ) + val updateLastCheckAt by settingsStore.updateLastCheckAt.collectAsStateWithLifecycle(initialValue = 0L) + var pendingRelease by remember { mutableStateOf(null) } + val currentVersion = remember { "v${BuildConfig.VERSION_NAME.removePrefix("v")}" } + val currentUpdatePostponeUntil by rememberUpdatedState(updatePostponeUntil) + val currentUpdatePostponeVersion by rememberUpdatedState(updatePostponeVersion) + val navItems = listOf( + NavItem(stringResource(R.string.nav_proxy), Icons.Default.PowerSettingsNew), + NavItem(stringResource(R.string.settings), Icons.Default.Settings), + NavItem(stringResource(R.string.nav_logs), Icons.Default.Terminal), + NavItem(stringResource(R.string.info), Icons.Default.Info) + ) + val safeBottomInset = with(density) { WindowInsets.safeDrawing.getBottom(density).toDp() } + val navOverlayReserve = safeBottomInset + 96.dp + + DisposableEffect(Unit) { + LogManager.startListening() + onDispose { LogManager.stopListening() } + } + + LaunchedEffect(updateCheckIntervalHours, updateLastCheckAt) { + if (updateCheckIntervalHours == UPDATE_CHECK_NEVER) return@LaunchedEffect + + val intervalMillis = updateIntervalHoursToMillis(updateCheckIntervalHours) + ?: updateIntervalHoursToMillis(DEFAULT_UPDATE_CHECK_INTERVAL_HOURS) + ?: 12L * 60L * 60L * 1000L + + if (updateLastCheckAt > 0L) { + val nextCheckAt = updateLastCheckAt + intervalMillis + val now = System.currentTimeMillis() + if (nextCheckAt > now) { + delay(nextCheckAt - now) + } + } + + if (!isActive) return@LaunchedEffect + + val checkedAt = System.currentTimeMillis() + val release = fetchLatestReleaseInfo(currentVersion) + settingsStore.saveUpdateState( + lastCheckAt = checkedAt, + latestVersion = release?.versionTag ?: "", + error = if (release == null) context.getString(R.string.update_check_failed_short) else "" + ) + + if (release == null) { + Log.w("TgWsProxy", "[WARN] Update check: no release info, local=$currentVersion") + } else { + val hasUpdate = isNewerVersion(currentVersion, release.versionTag) + val isPostponed = + currentUpdatePostponeVersion == release.versionTag && checkedAt < currentUpdatePostponeUntil + Log.i( + "TgWsProxy", + "Update check: local=$currentVersion remote=${release.versionTag} newer=$hasUpdate postponed=$isPostponed" + ) + + if (hasUpdate && !isPostponed) { + settingsStore.saveUpdateDialogShown(release.versionTag, checkedAt) + pendingRelease = release + } + } + } + + Scaffold( + contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top), + containerColor = Color.Transparent, + ) { padding -> + Box(modifier = Modifier + .fillMaxSize() + .padding(padding) + .consumeWindowInsets(padding) + .pointerInput(selectedTab) { + var totalDrag = 0f + detectHorizontalDragGestures( + onDragStart = { + totalDrag = 0f + dragTargetIndex = -1 + dragProgress = 0f + }, + onDragCancel = { + dragTargetIndex = -1 + dragProgress = 0f + }, + onDragEnd = { + if (dragTargetIndex in navItems.indices && dragProgress >= 0.5f) { + selectedTab = dragTargetIndex + } + dragTargetIndex = -1 + dragProgress = 0f + } + ) { change, dragAmount -> + change.consume() + totalDrag += dragAmount + if (abs(totalDrag) < 12f) { + dragTargetIndex = -1 + dragProgress = 0f + return@detectHorizontalDragGestures + } + + val candidate = if (totalDrag < 0f) selectedTab + 1 else selectedTab - 1 + if (candidate !in navItems.indices) { + dragTargetIndex = -1 + dragProgress = 0f + return@detectHorizontalDragGestures + } + + dragTargetIndex = candidate + dragProgress = (abs(totalDrag) / 180f).coerceIn(0f, 1f) + } + } + ) { + AnimatedContent( + targetState = selectedTab, + transitionSpec = { + fadeIn(tween(300)) togetherWith fadeOut(tween(225)) + }, + modifier = Modifier + .fillMaxSize() + .padding(bottom = navOverlayReserve), + label = "tab_content" + ) { page -> + when (page) { + 0 -> ConnectionTab(settingsStore) + 1 -> SettingsTab(settingsStore) + 2 -> LogsTab(settingsStore) + 3 -> InfoTab(settingsStore) + } + } + + ProxyNavigationBar( + navItems = navItems, + selectedTab = selectedTab, + dragTargetIndex = dragTargetIndex, + dragProgress = dragProgress, + onTabSelected = { index -> + selectedTab = index + dragTargetIndex = -1 + dragProgress = 0f + }, + modifier = Modifier.align(Alignment.BottomCenter) + ) + } + } + + pendingRelease?.let { release -> + AppUpdateDialog( + release = release, + onPostpone = { + pendingRelease = null + Toast.makeText(context, context.getString(R.string.update_postponed_24h), Toast.LENGTH_SHORT).show() + scope.launch { + val now = System.currentTimeMillis() + settingsStore.saveUpdatePostpone( + version = release.versionTag, + until = now + 24L * 60L * 60L * 1000L + ) + settingsStore.saveUpdateDialogAction( + version = release.versionTag, + action = UPDATE_DIALOG_ACTION_POSTPONED, + actedAt = now + ) + } + }, + onUpdate = { + pendingRelease = null + scope.launch { + settingsStore.saveUpdateDialogAction( + version = release.versionTag, + action = UPDATE_DIALOG_ACTION_UPDATE, + actedAt = System.currentTimeMillis() + ) + openUrlInBrowser(context, release.releaseUrl) + } + } + ) + } +} + +@Composable +private fun ProxyNavigationBar( + navItems: List, + selectedTab: Int, + dragTargetIndex: Int, + dragProgress: Float, + onTabSelected: (Int) -> Unit, + modifier: Modifier = Modifier +) { + val colors = MaterialTheme.colorScheme + val isDark = colors.background.luminance() < 0.22f + val selectedColor = colors.primary + val unselectedColor = colors.onSurfaceVariant.copy(alpha = 0.55f) + val shellColor = if (isDark) { + colors.surface.copy(alpha = 0.78f) + } else { + lerp(colors.surface, colors.surfaceVariant, 0.48f).copy(alpha = 0.95f) + } + val shellBorder = if (isDark) { + colors.outlineVariant.copy(alpha = 0.42f) + } else { + colors.outline.copy(alpha = 0.16f) + } + val indicatorColor = if (isDark) { + colors.primaryContainer.copy(alpha = 0.84f) + } else { + lerp(colors.primaryContainer, colors.surface, 0.18f).copy(alpha = 0.97f) + } + val indicatorIndex = remember { Animatable(selectedTab.toFloat()) } + val dragVisualIndex = indicatorIndex.value + + LaunchedEffect(selectedTab) { + if (dragTargetIndex !in navItems.indices) { + indicatorIndex.animateTo( + targetValue = selectedTab.toFloat(), + animationSpec = tween( + durationMillis = 720, + easing = CubicBezierEasing(0.2f, 0.9f, 0.24f, 1f) + ) + ) + } + } + + LaunchedEffect(selectedTab, dragTargetIndex, dragProgress) { + if (dragTargetIndex in navItems.indices) { + val target = selectedTab.toFloat() + (dragTargetIndex - selectedTab) * dragProgress + indicatorIndex.snapTo(target) + } + } + + BoxWithConstraints( + modifier = modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom)) + .padding(horizontal = 22.dp, vertical = 12.dp) + ) { + val trackPadding = 8.dp + val itemWidth = (maxWidth - trackPadding * 2) / navItems.size + val indicatorOffset = trackPadding + itemWidth * dragVisualIndex + + Surface( + shape = RoundedCornerShape(28.dp), + color = shellColor, + border = BorderStroke(1.dp, shellBorder), + tonalElevation = 0.dp, + shadowElevation = if (isDark) 10.dp else 8.dp, + modifier = Modifier.fillMaxWidth() + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(72.dp) + ) { + Surface( + shape = RoundedCornerShape(22.dp), + color = indicatorColor, + modifier = Modifier + .offset(x = indicatorOffset) + .padding(vertical = 6.dp) + .width(itemWidth) + .fillMaxHeight() + ) {} + + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = trackPadding, vertical = 6.dp) + ) { + navItems.forEachIndexed { index, item -> + val emphasis = (1f - abs(index - dragVisualIndex)).coerceIn(0f, 1f) + val iconColor = lerp(unselectedColor, selectedColor, emphasis) + + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .clip(RoundedCornerShape(22.dp)) + .clickable { onTabSelected(index) }, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = item.iconRes, + contentDescription = item.label, + modifier = Modifier.size(22.dp), + tint = iconColor + ) + Spacer(Modifier.height(4.dp)) + Text( + text = item.label, + style = MaterialTheme.typography.labelSmall, + fontWeight = if (emphasis > 0.55f) FontWeight.SemiBold else FontWeight.Medium, + color = iconColor, + maxLines = 1 + ) + } + } + } + } + } + } +} + +private fun android16OrbShape(points: Int, innerRatio: Float): Shape = GenericShape { size, _ -> + val centerX = size.width / 2f + val centerY = size.height / 2f + val outerRadius = min(size.width, size.height) / 2f + val innerRadius = outerRadius * innerRatio + + for (i in 0 until points * 2) { + val angle = (-PI / 2.0) + (i * PI / points) + val radius = if (i % 2 == 0) outerRadius else innerRadius + val x = centerX + (radius * cos(angle)).toFloat() + val y = centerY + (radius * sin(angle)).toFloat() + if (i == 0) moveTo(x, y) else lineTo(x, y) + } + close() +} + +private val Android16OrbLarge: Shape = android16OrbShape(points = 18, innerRatio = 0.90f) +private val Android16OrbMedium: Shape = android16OrbShape(points = 20, innerRatio = 0.92f) +private val Android16OrbSmall: Shape = android16OrbShape(points = 16, innerRatio = 0.88f) + +@Composable +private fun AppBackdrop(modifier: Modifier = Modifier) { + val colors = MaterialTheme.colorScheme + val isDark = colors.background.luminance() < 0.22f + val baseBrush = remember(colors.background, colors.surface, colors.surfaceVariant) { + Brush.verticalGradient( + colors = if (isDark) { + listOf( + lerp(colors.background, colors.surface, 0.42f), + colors.background, + lerp(colors.surfaceVariant, colors.background, 0.35f) + ) + } else { + listOf( + lerp(colors.background, colors.surface, 0.78f), + colors.background, + lerp(colors.surfaceVariant, colors.background, 0.30f) + ) + } + ) + } + val topGlow = colors.primary.copy(alpha = if (isDark) 0.16f else 0.09f) + val leftGlow = if (isDark) { + colors.tertiary.copy(alpha = 0.11f) + } else { + lerp(colors.tertiary, colors.secondaryContainer, 0.74f).copy(alpha = 0.24f) + } + val bottomGlow = if (isDark) { + colors.primary.copy(alpha = 0.10f) + } else { + lerp(colors.secondary, colors.primaryContainer, 0.70f).copy(alpha = 0.22f) + } + val lightOrbOutline = colors.outlineVariant.copy(alpha = 0.26f) + val topOrbGlow = if (isDark) { + topGlow + } else { + lerp(colors.primary, colors.primaryContainer, 0.72f).copy(alpha = 0.32f) + } + + Box( + modifier = modifier + .fillMaxSize() + .background(baseBrush) + ) { + Box( + modifier = Modifier + .align(Alignment.TopStart) + .offset(x = (-86).dp, y = (-126).dp) + .size(258.dp) + .clip(Android16OrbLarge) + .background(topOrbGlow) + .then( + if (isDark) Modifier else Modifier.border(1.dp, lightOrbOutline, Android16OrbLarge) + ) + ) + Box( + modifier = Modifier + .align(Alignment.CenterStart) + .offset(x = (-44).dp, y = 28.dp) + .size(146.dp) + .clip(Android16OrbSmall) + .background(leftGlow) + .then( + if (isDark) Modifier else Modifier.border(1.dp, lightOrbOutline.copy(alpha = 0.22f), Android16OrbSmall) + ) + ) + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .offset(x = 62.dp, y = (-208).dp) + .size(198.dp) + .clip(Android16OrbMedium) + .background(bottomGlow) + .then( + if (isDark) Modifier else Modifier.border(1.dp, lightOrbOutline.copy(alpha = 0.20f), Android16OrbMedium) + ) + ) + } +} + +/** + * Optimized LogManager: uses a Channel + batching approach to avoid + * creating a new list on every single log line — reduces GC pressure + * and eliminates UI jank caused by high-frequency log updates. + * + * Key optimizations: + * - Channel-based buffering: log lines are queued, not applied immediately + * - Batch processing: up to 20 lines applied per tick (every 150ms) + * - Array-backed list with cap of 50: avoids growing/shrinking allocations + * - Duplicate merging: last-entry count increment done in-place conceptually + */ +object LogManager { + val logs = MutableStateFlow>(emptyList()) + private var job: Job? = null + private var logcatProcess: Process? = null + private val nextKey = AtomicLong(0) + + // Buffered channel — absorbs bursts of log lines without blocking the reader + private val logChannel = Channel(capacity = BUFFERED) + + fun startListening() { + if (job?.isActive == true) return + job = CoroutineScope(Dispatchers.IO).launch { + // Start logcat reader coroutine + val readerJob = launch(Dispatchers.IO) { + try { + val pid = android.os.Process.myPid() + val process = ProcessBuilder("logcat", "-v", "tag", "--pid", pid.toString()) + .redirectErrorStream(true) + .start() + + logcatProcess = process + + process.inputStream.bufferedReader().use { reader -> + while (isActive) { + val line = try { reader.readLine() } catch (e: Exception) { null } ?: break + val entry = parseLine(line) ?: continue + logChannel.trySend(entry) + } + } + } catch (_: Exception) { + } finally { + logcatProcess?.destroy() + logcatProcess = null + } + } + + // Batch consumer: collects queued entries and applies in batches + launch { + val pendingBatch = mutableListOf() + while (isActive) { + // Drain the channel (non-blocking) + var received = logChannel.tryReceive() + while (received.isSuccess) { + pendingBatch.add(received.getOrThrow()) + if (pendingBatch.size >= 20) break // cap batch size + received = logChannel.tryReceive() + } + + if (pendingBatch.isNotEmpty()) { + // Apply batch to state — single list mutation + logs.value = applyBatch(logs.value, pendingBatch) + pendingBatch.clear() + } + + // Throttle updates — 150ms between UI refreshes + delay(150) + } + } + + readerJob.join() + } + } + + /** + * Efficiently applies a batch of new entries to the current log list. + * Merges consecutive duplicates and caps at 50 entries. + */ + private fun applyBatch(current: List, batch: List): List { + val result = ArrayDeque(current) + for (entry in batch) { + var merged = false + val searchDepth = minOf(result.size, 10) + for (i in result.indices.reversed().take(searchDepth)) { + if (result[i].message == entry.message) { + val existing = result.removeAt(i) + result.addLast(existing.copy(count = existing.count + 1)) + merged = true + break + } + } + if (!merged) { + result.addLast(entry) + } + } + while (result.size > 50) { + result.removeFirst() + } + return result.toList() + } + + fun stopListening() { + job?.cancel() + job = null + logcatProcess?.destroy() + logcatProcess = null + } + + fun clearLogs() { + logs.value = emptyList() + } + + private fun parseLine(raw: String): LogEntry? { + var message: String + val isError: Boolean + val priority: Int + + when { + raw.contains("[ERROR]") -> { + message = raw.substringAfter("[ERROR]").trim() + isError = true + priority = 6 // Log.ERROR + } + raw.contains("[WARN]") -> { + message = raw.substringAfter("[WARN]").trim() + isError = false // WARN is not ERROR, but distinctive + priority = 5 // Log.WARN + } + raw.contains("[DEBUG]") -> { + message = raw.substringAfter("[DEBUG]").trim() + isError = false + priority = 3 // Log.DEBUG + } + raw.contains("TgWsProxy") -> { + // Info doesn't have a prefix, so we strip basically everything up to the actual message + var msg = raw.substringAfter("TgWsProxy:").trim() + if (msg.startsWith("[ERROR]") || msg.startsWith("[WARN]") || msg.startsWith("[DEBUG]")) { + return null // Handled above, but just in case + } + + // Strip dynamic metrics like ↑3.3KB ↓1.1KB 0.3с so that lines can collapse + if (msg.contains("↑")) { + msg = msg.substringBefore("↑").trim() + } + if (msg.contains("↓")) { + msg = msg.substringBefore("↓").trim() + } + + message = msg + isError = false + priority = 4 // Log.INFO + } + else -> return null + } + + // Remove emojis and stickers + val emojiRegex = Regex("[\\x{1F300}-\\x{1F5FF}\\x{1F900}-\\x{1F9FF}\\x{1F600}-\\x{1F64F}\\x{1F680}-\\x{1F6FF}\\x{2600}-\\x{26FF}\\x{2700}-\\x{27BF}\\x{1F1E6}-\\x{1F1FF}\\x{1F191}-\\x{1F251}\\x{1F004}\\x{1F0CF}\\x{1F170}-\\x{1F171}\\x{1F17E}-\\x{1F17F}\\x{1F18E}\\x{3030}\\x{2B50}\\x{2B55}\\x{2934}-\\x{2935}\\x{2B05}-\\x{2B07}\\x{2B1B}-\\x{2B1C}\\x{3297}\\x{3299}\\x{303D}\\x{00A9}\\x{00AE}\\x{2122}\\x{23F3}\\x{24C2}\\x{23E9}-\\x{23EF}\\x{25B6}\\x{23F8}-\\x{23FA}⚠✅❌⚡🔥🔄🔗]") + message = message.replace(emojiRegex, "").trim() + + val isEssential = listOf( + "pool", "key:", "started", "address:", "error", "failed", "blocked", + "Пул", "Ключ:", "запущен", "Адрес:", "ошибка", "провалены", "заблокирован" + ).any { marker -> message.contains(marker, ignoreCase = true) } + + return LogEntry( + key = "log_${nextKey.getAndIncrement()}", + message = message, + count = 1, + isError = isError, + priority = priority, + isEssential = isEssential + ) + }} diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/NativeProxy.kt b/app/src/main/java/com/amurcanov/tgwsproxy/NativeProxy.kt new file mode 100644 index 00000000..2755ace5 --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/NativeProxy.kt @@ -0,0 +1,66 @@ +package com.amurcanov.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, secret: String, verbose: Int): Int + fun StopProxy(): Int + fun SetPoolSize(size: Int) + fun SetCfProxyCacheDir(cacheDir: String) + fun SetCfProxyConfig(enabled: Int, priority: Int, userDomain: String) + fun SetFakeTls(enabled: Int, domain: String) + fun GetSecretWithPrefix(): Pointer? + fun GetStats(): Pointer? + fun FreeString(p: Pointer) +} + +object NativeProxy { + fun startProxy(host: String, port: Int, dcIps: String, secret: String, verbose: Int): Int { + return ProxyLibrary.INSTANCE.StartProxy(host, port, dcIps, secret, verbose) + } + + fun stopProxy(): Int { + return ProxyLibrary.INSTANCE.StopProxy() + } + + fun setPoolSize(size: Int) { + ProxyLibrary.INSTANCE.SetPoolSize(size) + } + + fun setCfProxyCacheDir(cacheDir: String) { + ProxyLibrary.INSTANCE.SetCfProxyCacheDir(cacheDir) + } + + fun setCfProxyConfig(enabled: Boolean, priority: Boolean, userDomain: String) { + ProxyLibrary.INSTANCE.SetCfProxyConfig( + if (enabled) 1 else 0, + if (priority) 1 else 0, + userDomain + ) + } + + fun setFakeTls(enabled: Boolean, domain: String = "") { + ProxyLibrary.INSTANCE.SetFakeTls(if (enabled) 1 else 0, domain) + } + + /** Returns the full secret with correct prefix (dd or ee+domain_hex) */ + fun getSecretWithPrefix(): String? { + val ptr = ProxyLibrary.INSTANCE.GetSecretWithPrefix() ?: return null + val res = ptr.getString(0) + ProxyLibrary.INSTANCE.FreeString(ptr) + return res + } + + 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/amurcanov/tgwsproxy/ProxyController.kt b/app/src/main/java/com/amurcanov/tgwsproxy/ProxyController.kt new file mode 100644 index 00000000..f66747cb --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/ProxyController.kt @@ -0,0 +1,111 @@ +package com.amurcanov.tgwsproxy + +import android.content.Context +import android.content.Intent +import android.widget.Toast +import androidx.core.content.ContextCompat +import kotlinx.coroutines.flow.first + +object ProxyController { + + suspend fun startFromSavedSettings( + context: Context, + showInvalidPortToast: Boolean = false + ): Boolean { + val settingsStore = SettingsStore(context) + settingsStore.migrateLegacyDefaults() + val portText = settingsStore.port.first() + val port = portText.toIntOrNull() + if (port == null) { + if (showInvalidPortToast) { + Toast.makeText(context, context.getString(R.string.invalid_port), Toast.LENGTH_SHORT).show() + } + ProxyTileService.requestSync(context) + return false + } + + val isExperimental = settingsStore.isExperimentalMode.first() + val isDcAuto = settingsStore.isDcAuto.first() + val poolSize = settingsStore.poolSize.first() + val cfEnabled = settingsStore.cfproxyEnabled.first() + val customCfDomainEnabled = settingsStore.customCfDomainEnabled.first() + val customCfDomain = settingsStore.customCfDomain.first().trim() + val secretKey = ensureSecretKey(settingsStore) + + val parsedIps = buildList { + if (!isDcAuto) { + appendDc(1, settingsStore.dc1.first()) + appendDc(2, settingsStore.dc2.first()) + appendDc(3, settingsStore.dc3.first()) + appendDc(4, settingsStore.dc4.first()) + + if (isExperimental) { + appendDc(5, settingsStore.dc5.first()) + appendDc(203, settingsStore.dc203.first()) + appendDc(-1, settingsStore.dc1m.first()) + appendDc(-2, settingsStore.dc2m.first()) + appendDc(-3, settingsStore.dc3m.first()) + appendDc(-4, settingsStore.dc4m.first()) + appendDc(-5, settingsStore.dc5m.first()) + appendDc(-203, settingsStore.dc203m.first()) + } + } + }.joinToString(",") + + ContextCompat.startForegroundService( + context, + Intent(context, ProxyService::class.java).apply { + action = ProxyService.ACTION_START + putExtra(ProxyService.EXTRA_PORT, port) + putExtra(ProxyService.EXTRA_IPS, parsedIps) + putExtra(ProxyService.EXTRA_POOL_SIZE, poolSize) + putExtra(ProxyService.EXTRA_CFPROXY_ENABLED, cfEnabled) + putExtra(ProxyService.EXTRA_CFPROXY_PRIORITY, true) + putExtra( + ProxyService.EXTRA_CFPROXY_DOMAIN, + if (customCfDomainEnabled && cfEnabled) customCfDomain else "" + ) + putExtra(ProxyService.EXTRA_SECRET_KEY, secretKey) + } + ) + ProxyTileService.requestSync(context) + return true + } + + fun stop(context: Context) { + context.startService( + Intent(context, ProxyService::class.java).apply { + action = ProxyService.ACTION_STOP + } + ) + ProxyTileService.requestSync(context) + } + + private suspend fun ensureSecretKey(settingsStore: SettingsStore): String { + val current = settingsStore.secretKey.first().trim() + if (isValidSecret(current)) { + return current + } + + val generated = generateRandomSecret() + settingsStore.saveSecretKey(generated) + return generated + } + + private fun MutableList.appendDc(dc: Int, value: String) { + val ip = value.trim() + if (ip.isNotBlank()) { + add("$dc:$ip") + } + } + + private fun generateRandomSecret(): String { + val bytes = ByteArray(16) + java.security.SecureRandom().nextBytes(bytes) + return bytes.joinToString("") { "%02x".format(it) } + } + + private fun isValidSecret(value: String): Boolean { + return value.length == 32 && value.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' } + } +} diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/ProxyService.kt b/app/src/main/java/com/amurcanov/tgwsproxy/ProxyService.kt new file mode 100644 index 00000000..df64f1c1 --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/ProxyService.kt @@ -0,0 +1,515 @@ +package com.amurcanov.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 android.util.Log +import androidx.core.app.NotificationCompat +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import java.net.InetSocketAddress +import java.net.Socket + +class ProxyService : Service() { + + private var wakeLock: PowerManager.WakeLock? = null + private var statsJob: Job? = null + private var watchdogJob: Job? = null + private var restartJob: Job? = null + private var lastNotificationContent: String = "" + private var lastNotificationAtMs: Long = 0L + private var notificationStartedAtMs: Long = 0L + @Volatile + private var stopInProgress = false + private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + // Saved intent extras for restart on kill / onTaskRemoved + private var lastPort: Int = 1443 + private var lastIps: String = "" + private var lastPoolSize: Int = 4 + private var lastCfEnabled: Boolean = true + private var lastCfPriority: Boolean = true + private var lastCfDomain: String = "" + private var lastSecretKey: String = "" + + companion object { + const val ACTION_START = "com.amurcanov.tgwsproxy.START" + const val ACTION_STOP = "com.amurcanov.tgwsproxy.STOP" + const val ACTION_RESTART = "com.amurcanov.tgwsproxy.RESTART" + const val EXTRA_PORT = "EXTRA_PORT" + const val EXTRA_IPS = "EXTRA_IPS" + const val EXTRA_POOL_SIZE = "EXTRA_POOL_SIZE" + const val EXTRA_CFPROXY_ENABLED = "EXTRA_CFPROXY_ENABLED" + const val EXTRA_CFPROXY_PRIORITY = "EXTRA_CFPROXY_PRIORITY" + const val EXTRA_CFPROXY_DOMAIN = "EXTRA_CFPROXY_DOMAIN" + const val EXTRA_SECRET_KEY = "EXTRA_SECRET_KEY" + + private const val NOTIFICATION_ID = 101 + private const val CHANNEL_ID = "TG_WS_Proxy_Service_v4" + private const val TAG = "ProxyService" + + // Wakelock refresh interval (25 min, re-acquire before 30-min timeout) + private const val WAKELOCK_TIMEOUT_MS = 30L * 60 * 1000 + private const val WAKELOCK_REFRESH_MS = 25L * 60 * 1000 + + // Stats/notification update interval + private const val STATS_UPDATE_MS = 3_000L + private const val NOTIFICATION_MIN_UPDATE_MS = 3_000L + private const val NATIVE_STOP_WAIT_MS = 3_000L + + // Startup verification timeout + private const val STARTUP_CHECK_DELAY_MS = 3000L + + 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 -> { + LogManager.clearLogs() + val port = intent.getIntExtra(EXTRA_PORT, 1443) + val ips = intent.getStringExtra(EXTRA_IPS) ?: "" + val poolSize = intent.getIntExtra(EXTRA_POOL_SIZE, 4) + val cfEnabled = intent.getBooleanExtra(EXTRA_CFPROXY_ENABLED, true) + val cfPriority = intent.getBooleanExtra(EXTRA_CFPROXY_PRIORITY, true) + val cfDomain = intent.getStringExtra(EXTRA_CFPROXY_DOMAIN) ?: "" + val secretKey = intent.getStringExtra(EXTRA_SECRET_KEY) ?: "" + startProxy(port, ips, poolSize, cfEnabled, cfPriority, cfDomain, secretKey) + } + ACTION_STOP -> { + stopProxy() + } + ACTION_RESTART -> { + restartProxy() + } + null -> { + // Service restarted by system after being killed (START_REDELIVER_INTENT) + // If we had saved params, try to restart + if (lastPort > 0 && lastSecretKey.isNotEmpty()) { + Log.w(TAG, "Service restarted by system, re-starting proxy") + startProxy(lastPort, lastIps, lastPoolSize, lastCfEnabled, lastCfPriority, lastCfDomain, lastSecretKey) + } else { + stopSelf() + } + } + } + // START_REDELIVER_INTENT: if the system kills the service, it will restart it + // and re-deliver the last intent, so we don't lose the config. + return START_REDELIVER_INTENT + } + + private fun startProxy(port: Int, ips: String, poolSize: Int = 4, + cfEnabled: Boolean = true, cfPriority: Boolean = true, + cfDomain: String = "", secretKey: String = "") { + if (_isRunning.value || stopInProgress) return + + // Save params for restart + lastPort = port + lastIps = ips + lastPoolSize = poolSize + lastCfEnabled = cfEnabled + lastCfPriority = cfPriority + lastCfDomain = cfDomain + lastSecretKey = secretKey + notificationStartedAtMs = System.currentTimeMillis() + lastNotificationContent = getString(R.string.notification_starting) + lastNotificationAtMs = notificationStartedAtMs + + val notification = createNotification(lastNotificationContent) + 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() + stopInProgress = false + + // Start Go proxy in a separate thread with error handling + Thread({ + try { + NativeProxy.setPoolSize(poolSize) + NativeProxy.setCfProxyCacheDir(cacheDir.absolutePath) + NativeProxy.setCfProxyConfig(cfEnabled, cfPriority, cfDomain) + val result = NativeProxy.startProxy("127.0.0.1", port, ips, secretKey, 1) + if (result != 0) { + Log.e(TAG, "StartProxy returned error code: $result") + serviceScope.launch { + updateNotification(getString(R.string.notification_start_error_code, result), force = true) + delay(3000) + stopProxy() + } + } + } catch (e: Throwable) { + Log.e(TAG, "Failed to start proxy via JNA", e) + serviceScope.launch { + updateNotification(getString(R.string.notification_error, e.message ?: ""), force = true) + delay(3000) + stopProxy() + } + } + }, "ProxyStart").apply { + isDaemon = true + start() + } + + updateRunningState(true) + + // Watchdog: verify the proxy is actually listening after startup + watchdogJob = serviceScope.launch { + delay(STARTUP_CHECK_DELAY_MS) + if (_isRunning.value) { + val isListening = withContext(Dispatchers.IO) { + isPortOpen("127.0.0.1", port, 2000) + } + if (isListening) { + updateNotification(getString(R.string.notification_running), force = true) + Log.i(TAG, "Proxy verified: listening on port $port") + } else { + Log.e(TAG, "Proxy NOT listening on port $port after ${STARTUP_CHECK_DELAY_MS}ms") + updateNotification(getString(R.string.notification_not_responding), force = true) + // Don't stop — it might start slightly later; let the user decide + } + } + } + + // Stats updater. Notification updates are throttled so the system keeps + // a stable foreground entry instead of constantly reordering it. + statsJob = serviceScope.launch { + // WakeLock refresh sub-job: re-acquire before system timeout + launch { + while (isActive) { + delay(WAKELOCK_REFRESH_MS) + refreshWakeLock() + } + } + + while (isActive) { + delay(STATS_UPDATE_MS) + if (_isRunning.value && !stopInProgress) { + try { + val rawStats = NativeProxy.getStats() ?: continue + val upRaw = extractStat(rawStats, "up=") + val downRaw = extractStat(rawStats, "down=") + val activeConns = extractStat(rawStats, "active=") + + val totalBytes = parseHumanBytes(upRaw) + parseHumanBytes(downRaw) + val active = activeConns.toIntOrNull() ?: 0 + val text = getString(R.string.notification_traffic, formatBytes(totalBytes), active) + updateNotification(text) + } catch (e: Exception) { + Log.w(TAG, "Stats update failed", e) + } + } + } + } + } + + /** + * Check if a TCP port is reachable (used to verify proxy startup) + */ + private fun isPortOpen(host: String, port: Int, timeoutMs: Int): Boolean { + return try { + Socket().use { socket -> + socket.connect(InetSocketAddress(host, port), timeoutMs) + true + } + } catch (_: Exception) { + false + } + } + + private fun updateNotification(content: String, force: Boolean = false) { + val now = System.currentTimeMillis() + if (!force) { + if (content == lastNotificationContent) return + if (lastNotificationAtMs != 0L && now - lastNotificationAtMs < NOTIFICATION_MIN_UPDATE_MS) return + } + + lastNotificationContent = content + lastNotificationAtMs = now + try { + val manager = getSystemService(NotificationManager::class.java) + manager?.notify(NOTIFICATION_ID, createNotification(content)) + } catch (e: Exception) { + Log.w(TAG, "Failed to update notification", e) + } + } + + private fun restartProxy() { + if (restartJob?.isActive == true) return + if (lastPort <= 0 || lastSecretKey.isEmpty()) { + Log.w(TAG, "Restart requested without saved proxy configuration") + return + } + + restartJob = serviceScope.launch { + Log.i(TAG, "Restarting proxy from notification") + updateNotification(getString(R.string.notification_restarting), force = true) + + watchdogJob?.cancel() + watchdogJob = null + statsJob?.cancel() + statsJob = null + + requestNativeStop("restart") + releaseWakeLock() + updateRunningState(false) + delay(350) + + startProxy( + port = lastPort, + ips = lastIps, + poolSize = lastPoolSize, + cfEnabled = lastCfEnabled, + cfPriority = lastCfPriority, + cfDomain = lastCfDomain, + secretKey = lastSecretKey + ) + } + } + + 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() { + if (stopInProgress) return + stopInProgress = true + restartJob?.cancel() + restartJob = null + watchdogJob?.cancel() + watchdogJob = null + statsJob?.cancel() + statsJob = null + serviceScope.launch { + updateNotification(getString(R.string.notification_stopping), force = true) + requestNativeStop("stop") + releaseWakeLock() + updateRunningState(false) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + stopForeground(STOP_FOREGROUND_REMOVE) + } else { + @Suppress("DEPRECATION") + stopForeground(true) + } + stopSelf() + } + } + + private suspend fun requestNativeStop(reason: String): Boolean { + val completed = CompletableDeferred() + Thread({ + try { + NativeProxy.stopProxy() + } catch (e: Exception) { + Log.w(TAG, "StopProxy failed during $reason", e) + } finally { + completed.complete(Unit) + } + }, "ProxyStop-$reason").apply { + isDaemon = true + start() + } + + val finished = withTimeoutOrNull(NATIVE_STOP_WAIT_MS) { + completed.await() + true + } ?: false + + if (!finished) { + Log.w(TAG, "Native stop is still running after ${NATIVE_STOP_WAIT_MS}ms during $reason") + } + return finished + } + + /** + * Called when the user swipes the app from recents. + * Without this, the service would be killed on many OEM Androids. + */ + override fun onTaskRemoved(rootIntent: Intent?) { + super.onTaskRemoved(rootIntent) + if (_isRunning.value) { + Log.w(TAG, "onTaskRemoved: proxy is running, service stays alive") + // The service continues because stopWithTask=false in manifest + // No action needed — the service keeps running. + } + } + + private fun acquireWakeLock() { + try { + val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager + wakeLock = powerManager.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + "TgWsProxy::ServiceWakeLock" + ).apply { + // Acquire with timeout. System may ignore indefinite wakelocks. + acquire(WAKELOCK_TIMEOUT_MS) + } + Log.d(TAG, "WakeLock acquired (${WAKELOCK_TIMEOUT_MS / 60000}min)") + } catch (e: Exception) { + Log.w(TAG, "Failed to acquire WakeLock", e) + } + } + + /** + * Periodically refresh wakelock to prevent system from expiring it. + */ + private fun refreshWakeLock() { + try { + wakeLock?.let { + if (it.isHeld) { + it.release() + } + } + val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager + wakeLock = powerManager.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + "TgWsProxy::ServiceWakeLock" + ).apply { + acquire(WAKELOCK_TIMEOUT_MS) + } + Log.d(TAG, "WakeLock refreshed") + } catch (e: Exception) { + Log.w(TAG, "Failed to refresh WakeLock", e) + } + } + + private fun releaseWakeLock() { + try { + wakeLock?.let { + if (it.isHeld) { + it.release() + } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to release WakeLock", e) + } + wakeLock = null + } + + private fun updateRunningState(isRunning: Boolean) { + _isRunning.value = isRunning + ProxyTileService.requestSync(this) + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val serviceChannel = NotificationChannel( + CHANNEL_ID, + getString(R.string.notification_channel_name), + NotificationManager.IMPORTANCE_LOW + ).apply { + description = getString(R.string.notification_channel_description) + setShowBadge(false) + setSound(null, null) + enableVibration(false) + enableLights(false) + lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC + } + val manager = getSystemService(NotificationManager::class.java) + manager?.createNotificationChannel(serviceChannel) + } + } + + private fun createNotification(content: String): Notification { + val openIntent = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val openPendingIntent = PendingIntent.getActivity( + this, 1, openIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val stopIntent = Intent(this, ProxyService::class.java).apply { + action = ACTION_STOP + } + val restartIntent = Intent(this, ProxyService::class.java).apply { + action = ACTION_RESTART + } + val restartPendingIntent = PendingIntent.getService( + this, 2, restartIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + 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) + .setContentIntent(openPendingIntent) // Tap notification → open app + .addAction(android.R.drawable.ic_popup_sync, getString(R.string.notification_restart), restartPendingIntent) + .addAction(android.R.drawable.ic_menu_close_clear_cancel, getString(R.string.notification_disconnect), stopPendingIntent) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setSilent(true) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setWhen(notificationStartedAtMs.takeIf { it > 0L } ?: System.currentTimeMillis()) + .setShowWhen(false) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .build() + } + + override fun onDestroy() { + restartJob?.cancel() + restartJob = null + watchdogJob?.cancel() + watchdogJob = null + statsJob?.cancel() + statsJob = null + releaseWakeLock() + if (_isRunning.value) { + updateRunningState(false) + } + serviceScope.cancel() + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? { + return null + } +} diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/ProxyTilePreferencesActivity.kt b/app/src/main/java/com/amurcanov/tgwsproxy/ProxyTilePreferencesActivity.kt new file mode 100644 index 00000000..023c917c --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/ProxyTilePreferencesActivity.kt @@ -0,0 +1,33 @@ +package com.amurcanov.tgwsproxy + +import android.app.Activity +import android.content.Intent +import android.os.Bundle + +class ProxyTilePreferencesActivity : Activity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + openApp() + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + setIntent(intent) + openApp() + } + + private fun openApp() { + startActivity( + Intent(this, MainActivity::class.java).apply { + addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_SINGLE_TOP + ) + } + ) + finish() + overridePendingTransition(0, 0) + } +} diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/ProxyTileService.kt b/app/src/main/java/com/amurcanov/tgwsproxy/ProxyTileService.kt new file mode 100644 index 00000000..24d18965 --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/ProxyTileService.kt @@ -0,0 +1,99 @@ +package com.amurcanov.tgwsproxy + +import android.content.ComponentName +import android.content.Context +import android.graphics.drawable.Icon +import android.os.Build +import android.service.quicksettings.Tile +import android.service.quicksettings.TileService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +class ProxyTileService : TileService() { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private var listenJob: Job? = null + + override fun onStartListening() { + super.onStartListening() + listenJob?.cancel() + listenJob = scope.launch { + ProxyService.isRunning.collectLatest { isRunning -> + renderTile( + if (isRunning) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE + ) + } + } + renderTile() + } + + override fun onStopListening() { + listenJob?.cancel() + listenJob = null + super.onStopListening() + } + + override fun onClick() { + super.onClick() + + val toggleAction: () -> Unit = { + scope.launch { + val wasRunning = ProxyService.isRunning.value + if (wasRunning) { + renderTile(Tile.STATE_INACTIVE) + ProxyController.stop(this@ProxyTileService) + } else { + val started = ProxyController.startFromSavedSettings( + context = this@ProxyTileService, + showInvalidPortToast = true + ) + renderTile( + if (started) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE + ) + } + } + } + + if (isLocked) { + unlockAndRun(toggleAction) + } else { + toggleAction() + } + } + + override fun onDestroy() { + listenJob?.cancel() + scope.cancel() + super.onDestroy() + } + + private fun renderTile(overrideState: Int? = null) { + qsTile?.apply { + label = "Telegram WS Proxy" + icon = Icon.createWithResource(this@ProxyTileService, R.drawable.ic_qs_proxy_t) + state = overrideState ?: if (ProxyService.isRunning.value) { + Tile.STATE_ACTIVE + } else { + Tile.STATE_INACTIVE + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + subtitle = if (state == Tile.STATE_ACTIVE) getString(R.string.tile_connected) else getString(R.string.tile_disconnected) + } + contentDescription = label + updateTile() + } + } + + companion object { + fun requestSync(context: Context) { + runCatching { + requestListeningState(context, ComponentName(context, ProxyTileService::class.java)) + } + } + } +} diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/SettingsStore.kt b/app/src/main/java/com/amurcanov/tgwsproxy/SettingsStore.kt new file mode 100644 index 00000000..bf9cb91f --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/SettingsStore.kt @@ -0,0 +1,216 @@ +package com.amurcanov.tgwsproxy + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.dataStore: DataStore by preferencesDataStore(name = "proxy_settings") + +class SettingsStore(private val context: Context) { + + companion object { + const val DEFAULT_DIRECT_DC2_IP = "149.154.167.220" + const val DEFAULT_DIRECT_DC4_IP = "149.154.167.220" + private const val LEGACY_DIRECT_DC_IP = "149.154.167.220" + const val DEFAULT_LOG_SHOW_INFO = true + const val DEFAULT_LOG_SHOW_ERROR = false + } + + private object Keys { + val THEME_MODE = stringPreferencesKey("theme_mode") + val IS_DYNAMIC_COLOR = booleanPreferencesKey("is_dynamic_color") + val THEME_PALETTE = stringPreferencesKey("theme_palette") + val IS_DC_AUTO = booleanPreferencesKey("is_dc_auto") + val DC1 = stringPreferencesKey("dc1") + val DC2 = stringPreferencesKey("dc2") + val DC3 = stringPreferencesKey("dc3") + val DC4 = stringPreferencesKey("dc4") + val PORT = stringPreferencesKey("port") + val POOL_SIZE = intPreferencesKey("pool_size") + val CFPROXY_ENABLED = booleanPreferencesKey("cfproxy_enabled") + val CUSTOM_CF_DOMAIN_ENABLED = booleanPreferencesKey("custom_cf_domain_enabled") + val CUSTOM_CF_DOMAIN = stringPreferencesKey("custom_cf_domain") + val AUTO_START_ON_BOOT = booleanPreferencesKey("auto_start_on_boot") + val SECRET_KEY = stringPreferencesKey("secret_key") + val LOG_SHOW_DEBUG = booleanPreferencesKey("log_show_debug") + val LOG_SHOW_INFO = booleanPreferencesKey("log_show_info") + val LOG_SHOW_ERROR = booleanPreferencesKey("log_show_error") + val LOG_SHOW_NULL = booleanPreferencesKey("log_show_null") + val IS_EXPERIMENTAL_MODE = booleanPreferencesKey("is_experimental_mode") + val UPDATE_LAST_CHECK_AT = longPreferencesKey("update_last_check_at") + val UPDATE_LATEST_VERSION = stringPreferencesKey("update_latest_version") + val UPDATE_LAST_ERROR = stringPreferencesKey("update_last_error") + val UPDATE_CHECK_INTERVAL_HOURS = intPreferencesKey("update_check_interval_hours") + val UPDATE_POSTPONE_UNTIL = longPreferencesKey("update_postpone_until") + val UPDATE_POSTPONE_VERSION = stringPreferencesKey("update_postpone_version") + val UPDATE_DIALOG_LAST_SHOWN_VERSION = stringPreferencesKey("update_dialog_last_shown_version") + val UPDATE_DIALOG_LAST_SHOWN_AT = longPreferencesKey("update_dialog_last_shown_at") + val UPDATE_DIALOG_LAST_ACTION_VERSION = stringPreferencesKey("update_dialog_last_action_version") + val UPDATE_DIALOG_LAST_ACTION = stringPreferencesKey("update_dialog_last_action") + val UPDATE_DIALOG_LAST_ACTION_AT = longPreferencesKey("update_dialog_last_action_at") + val DIRECT_DC_DEFAULTS_MIGRATED = booleanPreferencesKey("direct_dc_defaults_migrated") + val DIRECT_DC_DEFAULTS_V2_MIGRATED = booleanPreferencesKey("direct_dc_defaults_v2_migrated") + } + + val isReady: Flow = context.dataStore.data.map { true } + val isExperimentalMode: Flow = context.dataStore.data.map { it[Keys.IS_EXPERIMENTAL_MODE] ?: false } + val themeMode: Flow = context.dataStore.data.map { it[Keys.THEME_MODE] ?: "system" } + val isDynamicColor: Flow = context.dataStore.data.map { it[Keys.IS_DYNAMIC_COLOR] ?: true } + val themePalette: Flow = context.dataStore.data.map { it[Keys.THEME_PALETTE] ?: "indigo" } + val isDcAuto: Flow = context.dataStore.data.map { it[Keys.IS_DC_AUTO] ?: true } + val dc1: Flow = context.dataStore.data.map { it[Keys.DC1] ?: "" } + val dc2: Flow = context.dataStore.data.map { it[Keys.DC2] ?: DEFAULT_DIRECT_DC2_IP } + val dc3: Flow = context.dataStore.data.map { it[Keys.DC3] ?: "" } + val dc4: Flow = context.dataStore.data.map { it[Keys.DC4] ?: DEFAULT_DIRECT_DC4_IP } + val dc5: Flow = context.dataStore.data.map { it[stringPreferencesKey("dc5")] ?: "" } + val dc203: Flow = context.dataStore.data.map { it[stringPreferencesKey("dc203")] ?: "" } + val dc1m: Flow = context.dataStore.data.map { it[stringPreferencesKey("dc1m")] ?: "" } + val dc2m: Flow = context.dataStore.data.map { it[stringPreferencesKey("dc2m")] ?: "" } + val dc3m: Flow = context.dataStore.data.map { it[stringPreferencesKey("dc3m")] ?: "" } + val dc4m: Flow = context.dataStore.data.map { it[stringPreferencesKey("dc4m")] ?: "" } + val dc5m: Flow = context.dataStore.data.map { it[stringPreferencesKey("dc5m")] ?: "" } + val dc203m: Flow = context.dataStore.data.map { it[stringPreferencesKey("dc203m")] ?: "" } + val port: Flow = context.dataStore.data.map { it[Keys.PORT] ?: "1443" } + val poolSize: Flow = context.dataStore.data.map { it[Keys.POOL_SIZE] ?: 4 } + val cfproxyEnabled: Flow = context.dataStore.data.map { it[Keys.CFPROXY_ENABLED] ?: true } + val customCfDomainEnabled: Flow = context.dataStore.data.map { it[Keys.CUSTOM_CF_DOMAIN_ENABLED] ?: false } + val customCfDomain: Flow = context.dataStore.data.map { it[Keys.CUSTOM_CF_DOMAIN] ?: "" } + val autoStartOnBoot: Flow = context.dataStore.data.map { it[Keys.AUTO_START_ON_BOOT] ?: false } + val secretKey: Flow = context.dataStore.data.map { it[Keys.SECRET_KEY] ?: "" } + + val logShowDebug: Flow = context.dataStore.data.map { it[Keys.LOG_SHOW_DEBUG] ?: false } + val logShowInfo: Flow = context.dataStore.data.map { it[Keys.LOG_SHOW_INFO] ?: DEFAULT_LOG_SHOW_INFO } + val logShowError: Flow = context.dataStore.data.map { it[Keys.LOG_SHOW_ERROR] ?: DEFAULT_LOG_SHOW_ERROR } + val logShowNull: Flow = context.dataStore.data.map { it[Keys.LOG_SHOW_NULL] ?: false } + val updateLastCheckAt: Flow = context.dataStore.data.map { it[Keys.UPDATE_LAST_CHECK_AT] ?: 0L } + val updateLatestVersion: Flow = context.dataStore.data.map { it[Keys.UPDATE_LATEST_VERSION] ?: "" } + val updateLastError: Flow = context.dataStore.data.map { it[Keys.UPDATE_LAST_ERROR] ?: "" } + val updateCheckIntervalHours: Flow = context.dataStore.data.map { + it[Keys.UPDATE_CHECK_INTERVAL_HOURS] ?: DEFAULT_UPDATE_CHECK_INTERVAL_HOURS + } + val updatePostponeUntil: Flow = context.dataStore.data.map { it[Keys.UPDATE_POSTPONE_UNTIL] ?: 0L } + val updatePostponeVersion: Flow = context.dataStore.data.map { it[Keys.UPDATE_POSTPONE_VERSION] ?: "" } + val updateDialogLastShownVersion: Flow = context.dataStore.data.map { it[Keys.UPDATE_DIALOG_LAST_SHOWN_VERSION] ?: "" } + val updateDialogLastShownAt: Flow = context.dataStore.data.map { it[Keys.UPDATE_DIALOG_LAST_SHOWN_AT] ?: 0L } + val updateDialogLastActionVersion: Flow = context.dataStore.data.map { it[Keys.UPDATE_DIALOG_LAST_ACTION_VERSION] ?: "" } + val updateDialogLastAction: Flow = context.dataStore.data.map { it[Keys.UPDATE_DIALOG_LAST_ACTION] ?: "" } + val updateDialogLastActionAt: Flow = context.dataStore.data.map { it[Keys.UPDATE_DIALOG_LAST_ACTION_AT] ?: 0L } + + suspend fun saveSecretKey(key: String) { + context.dataStore.edit { it[Keys.SECRET_KEY] = key } + } + + suspend fun saveThemeMode(mode: String) { + context.dataStore.edit { it[Keys.THEME_MODE] = mode } + } + + suspend fun saveDynamicColor(enabled: Boolean) { + context.dataStore.edit { it[Keys.IS_DYNAMIC_COLOR] = enabled } + } + + suspend fun saveThemePalette(palette: String) { + context.dataStore.edit { it[Keys.THEME_PALETTE] = palette } + } + + suspend fun saveLogFilters(debug: Boolean, info: Boolean, error: Boolean, isNull: Boolean) { + context.dataStore.edit { + it[Keys.LOG_SHOW_DEBUG] = debug + it[Keys.LOG_SHOW_INFO] = info + it[Keys.LOG_SHOW_ERROR] = error + it[Keys.LOG_SHOW_NULL] = isNull + } + } + + suspend fun saveUpdateState(lastCheckAt: Long, latestVersion: String, error: String) { + context.dataStore.edit { + it[Keys.UPDATE_LAST_CHECK_AT] = lastCheckAt + it[Keys.UPDATE_LATEST_VERSION] = latestVersion + it[Keys.UPDATE_LAST_ERROR] = error + } + } + + suspend fun saveUpdateCheckIntervalHours(hours: Int) { + context.dataStore.edit { it[Keys.UPDATE_CHECK_INTERVAL_HOURS] = hours } + } + + suspend fun saveAutoStartOnBoot(enabled: Boolean) { + context.dataStore.edit { it[Keys.AUTO_START_ON_BOOT] = enabled } + } + + suspend fun saveUpdatePostpone(version: String, until: Long) { + context.dataStore.edit { + it[Keys.UPDATE_POSTPONE_VERSION] = version + it[Keys.UPDATE_POSTPONE_UNTIL] = until + } + } + + suspend fun saveUpdateDialogShown(version: String, shownAt: Long) { + context.dataStore.edit { + it[Keys.UPDATE_DIALOG_LAST_SHOWN_VERSION] = version + it[Keys.UPDATE_DIALOG_LAST_SHOWN_AT] = shownAt + } + } + + suspend fun saveUpdateDialogAction(version: String, action: String, actedAt: Long) { + context.dataStore.edit { + it[Keys.UPDATE_DIALOG_LAST_ACTION_VERSION] = version + it[Keys.UPDATE_DIALOG_LAST_ACTION] = action + it[Keys.UPDATE_DIALOG_LAST_ACTION_AT] = actedAt + } + } + + suspend fun migrateLegacyDefaults() { + context.dataStore.edit { + if (it[Keys.DIRECT_DC_DEFAULTS_V2_MIGRATED] == true) return@edit + + val dc2 = it[Keys.DC2].orEmpty().trim() + if (dc2.isBlank() || dc2 == LEGACY_DIRECT_DC_IP) { + it[Keys.DC2] = DEFAULT_DIRECT_DC2_IP + } + + val dc4 = it[Keys.DC4].orEmpty().trim() + if (dc4.isBlank() || dc4 == LEGACY_DIRECT_DC_IP) { + it[Keys.DC4] = DEFAULT_DIRECT_DC4_IP + } + + it[Keys.DIRECT_DC_DEFAULTS_MIGRATED] = true + it[Keys.DIRECT_DC_DEFAULTS_V2_MIGRATED] = true + } + } + + suspend fun saveAll(isDcAuto: Boolean, dc1: String, dc2: String, dc3: String, dc4: String, dc5: String, dc203: String, + dc1m: String, dc2m: String, dc3m: String, dc4m: String, dc5m: String, dc203m: String, + isExperimental: Boolean, port: String, poolSize: Int, + cfproxyEnabled: Boolean, customCfDomainEnabled: Boolean, customCfDomain: String, secretKey: String) { + context.dataStore.edit { + it[Keys.IS_DC_AUTO] = isDcAuto + it[Keys.DC1] = dc1 + it[Keys.DC2] = dc2 + it[Keys.DC3] = dc3 + it[Keys.DC4] = dc4 + it[stringPreferencesKey("dc5")] = dc5 + it[stringPreferencesKey("dc203")] = dc203 + it[stringPreferencesKey("dc1m")] = dc1m + it[stringPreferencesKey("dc2m")] = dc2m + it[stringPreferencesKey("dc3m")] = dc3m + it[stringPreferencesKey("dc4m")] = dc4m + it[stringPreferencesKey("dc5m")] = dc5m + it[stringPreferencesKey("dc203m")] = dc203m + it[Keys.IS_EXPERIMENTAL_MODE] = isExperimental + it[Keys.PORT] = port + it[Keys.POOL_SIZE] = poolSize + it[Keys.CFPROXY_ENABLED] = cfproxyEnabled + it[Keys.CUSTOM_CF_DOMAIN_ENABLED] = customCfDomainEnabled + it[Keys.CUSTOM_CF_DOMAIN] = customCfDomain + it[Keys.SECRET_KEY] = secretKey + } + } +} diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/ui/AppSectionCard.kt b/app/src/main/java/com/amurcanov/tgwsproxy/ui/AppSectionCard.kt new file mode 100644 index 00000000..0d3c349e --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/ui/AppSectionCard.kt @@ -0,0 +1,65 @@ +package com.amurcanov.tgwsproxy.ui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.unit.dp + +@Composable +private fun appSectionCardColor(): Color { + val colors = MaterialTheme.colorScheme + val isDark = colors.background.luminance() < 0.22f + return if (isDark) { + lerp(colors.surface, colors.primaryContainer, 0.20f) + } else { + lerp(colors.surface, colors.surfaceVariant, 0.28f) + } +} + +@Composable +private fun appSectionCardBorderColor(): Color { + val colors = MaterialTheme.colorScheme + val isDark = colors.background.luminance() < 0.22f + return if (isDark) { + colors.outlineVariant.copy(alpha = 0.52f) + } else { + colors.outlineVariant.copy(alpha = 0.24f) + } +} + +@Composable +fun AppSectionCard( + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(horizontal = 18.dp, vertical = 18.dp), + verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(16.dp), + content: @Composable ColumnScope.() -> Unit +) { + Surface( + shape = RoundedCornerShape(28.dp), + color = appSectionCardColor(), + border = BorderStroke(1.dp, appSectionCardBorderColor()), + shadowElevation = 10.dp, + tonalElevation = 2.dp, + modifier = modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(contentPadding), + verticalArrangement = verticalArrangement, + content = content + ) + } +} diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/ui/AppUpdateDialog.kt b/app/src/main/java/com/amurcanov/tgwsproxy/ui/AppUpdateDialog.kt new file mode 100644 index 00000000..f7cccc43 --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/ui/AppUpdateDialog.kt @@ -0,0 +1,135 @@ +package com.amurcanov.tgwsproxy.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Update +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.amurcanov.tgwsproxy.AppReleaseInfo +import com.amurcanov.tgwsproxy.RemoteVersionSource + +@Composable +fun AppUpdateDialog( + release: AppReleaseInfo, + onPostpone: () -> Unit, + onUpdate: () -> Unit +) { + val isTagOnly = release.source == RemoteVersionSource.Tag + val title = if (isTagOnly) stringResource(com.amurcanov.tgwsproxy.R.string.update_new_tag_title) else stringResource(com.amurcanov.tgwsproxy.R.string.update_available_title) + val description = if (isTagOnly) { + stringResource(com.amurcanov.tgwsproxy.R.string.update_new_tag_description, release.versionTag) + } else { + stringResource(com.amurcanov.tgwsproxy.R.string.update_available_description, release.versionTag) + } + val actionLabel = stringResource(com.amurcanov.tgwsproxy.R.string.update_action) + + Dialog( + onDismissRequest = {}, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = false, + dismissOnClickOutside = false + ) + ) { + Surface( + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 8.dp, + modifier = Modifier.fillMaxWidth(0.92f) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 22.dp, vertical = 20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Update, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(10.dp)) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = release.versionTag, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 20.sp + ) + + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + onClick = onPostpone, + modifier = Modifier + .weight(1f) + .height(50.dp), + shape = RoundedCornerShape(22.dp) + ) { + Text( + text = stringResource(com.amurcanov.tgwsproxy.R.string.later), + fontWeight = FontWeight.SemiBold + ) + } + + Button( + onClick = onUpdate, + modifier = Modifier + .weight(1f) + .height(50.dp), + shape = RoundedCornerShape(22.dp) + ) { + Text( + text = actionLabel, + fontWeight = FontWeight.Bold + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/ui/ConnectionTab.kt b/app/src/main/java/com/amurcanov/tgwsproxy/ui/ConnectionTab.kt new file mode 100644 index 00000000..1c7de8da --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/ui/ConnectionTab.kt @@ -0,0 +1,350 @@ +package com.amurcanov.tgwsproxy.ui + +import android.content.Context +import android.widget.Toast +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.* +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.Image +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.amurcanov.tgwsproxy.BuildConfig +import com.amurcanov.tgwsproxy.ProxyController +import com.amurcanov.tgwsproxy.ProxyService +import com.amurcanov.tgwsproxy.SettingsStore +import com.amurcanov.tgwsproxy.R +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun ConnectionTab(settingsStore: SettingsStore) { + val context = LocalContext.current + val isRunning by ProxyService.isRunning.collectAsStateWithLifecycle() + + val isReady by settingsStore.isReady.collectAsStateWithLifecycle(initialValue = false) + + // Settings + val savedPort by settingsStore.port.collectAsStateWithLifecycle(initialValue = "1443") + val savedCfEnabled by settingsStore.cfproxyEnabled.collectAsStateWithLifecycle(initialValue = true) + val savedPoolSize by settingsStore.poolSize.collectAsStateWithLifecycle(initialValue = 4) + val savedSecretKey by settingsStore.secretKey.collectAsStateWithLifecycle(initialValue = "LOADING") + + val scope = rememberCoroutineScope() + val currentVersion = remember { "v${BuildConfig.VERSION_NAME.removePrefix("v")}" } + + if (!isReady || savedSecretKey == "LOADING") { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + } + return + } + + // Auto-generate secret if empty + LaunchedEffect(savedSecretKey) { + if (savedSecretKey == "") { + val bytes = ByteArray(16) + java.security.SecureRandom().nextBytes(bytes) + val generated = bytes.joinToString("") { "%02x".format(it) } + scope.launch { settingsStore.saveSecretKey(generated) } + } + } + + var isStarting by remember { mutableStateOf(false) } + val statusText = when { + isStarting -> stringResource(R.string.status_connecting) + isRunning -> stringResource(R.string.status_connected) + else -> stringResource(R.string.status_disconnected) + } + + LaunchedEffect(isRunning) { + if (isRunning) { + delay(600) + isStarting = false + } + if (!isRunning) { + isStarting = false + } + } + + val port = savedPort.toIntOrNull() ?: 1443 + val secretForUrl = remember(savedSecretKey) { + val raw = savedSecretKey.trim() + if (raw.isNotEmpty() && raw != "LOADING") raw else "00000000000000000000000000000000" + } + val proxyUrl = "https://t.me/proxy?server=127.0.0.1&port=$port&secret=dd$secretForUrl" + + val connectAction = { + if (!isRunning && !isStarting) { + isStarting = true + scope.launch { + val started = ProxyController.startFromSavedSettings( + context = context, + showInvalidPortToast = true + ) + if (!started) { + isStarting = false + } + } + } + } + + val disconnectAction = { + if (isRunning || isStarting) { + ProxyController.stop(context) + } + } + + val isActiveVisual = isRunning || isStarting + val logoScale by animateFloatAsState( + targetValue = if (isActiveVisual) 1.12f else 0.94f, + animationSpec = tween(durationMillis = 650, easing = CubicBezierEasing(0.22f, 1f, 0.36f, 1f)), + label = "logo_scale" + ) + val logoInteractionSource = remember { MutableInteractionSource() } + val statusColor by animateColorAsState( + targetValue = if (isActiveVisual) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + label = "connection_status_color" + ) + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 20.dp) + .padding(top = 0.dp, bottom = 16.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.section_launch), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center + ) { + AppSectionCard( + modifier = Modifier + .fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Image( + painter = painterResource(id = R.drawable.ic_telegram_logo), + contentDescription = null, + modifier = Modifier + .size(180.dp) + .clip(RoundedCornerShape(40.dp)) + .clickable( + interactionSource = logoInteractionSource, + indication = null, + onClick = if (isActiveVisual) disconnectAction else connectAction + ) + .scale(logoScale), + colorFilter = if (isActiveVisual) null else ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }), + alpha = if (isActiveVisual) 1f else 0.52f + ) + Text( + text = statusText, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = statusColor, + textAlign = TextAlign.Center + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Button( + onClick = { openTelegram(context, proxyUrl) }, + enabled = isRunning, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + shape = RoundedCornerShape(24.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.25f) + ) + ) { + Text( + stringResource(R.string.apply_in_telegram), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + + ProxyStatusPanel( + cfEnabled = savedCfEnabled, + poolSize = savedPoolSize, + port = savedPort, + version = currentVersion + ) + + Surface( + onClick = { + val cb = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + cb.setPrimaryClip(android.content.ClipData.newPlainText("Proxy", proxyUrl)) + Toast.makeText(context, context.getString(R.string.copied), Toast.LENGTH_SHORT).show() + }, + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)), + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + Text( + text = proxyUrl, + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + maxLines = 1, + modifier = Modifier.weight(1f) + ) + Icon( + Icons.Default.ContentCopy, + contentDescription = stringResource(R.string.copy), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + } + } + } + } + } + } + } +} + +@Composable +private fun ProxyStatusPanel( + cfEnabled: Boolean, + poolSize: Int, + port: String, + version: String +) { + Surface( + shape = RoundedCornerShape(22.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .padding(horizontal = 4.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ProxyStatusItem( + text = if (cfEnabled) "CF" else stringResource(R.string.direct_mode), + modifier = Modifier + .weight(0.9f) + .padding(horizontal = 6.dp, vertical = 8.dp) + ) + ProxyStatusDivider() + ProxyStatusItem( + text = stringResource(R.string.pool_short, poolSize), + modifier = Modifier + .weight(1.05f) + .padding(horizontal = 6.dp, vertical = 8.dp) + ) + ProxyStatusDivider() + ProxyStatusItem( + text = stringResource(R.string.port_short, port), + modifier = Modifier + .weight(1.35f) + .padding(horizontal = 6.dp, vertical = 8.dp) + ) + ProxyStatusDivider() + ProxyStatusItem( + text = version, + modifier = Modifier + .weight(1.1f) + .padding(horizontal = 6.dp, vertical = 8.dp) + ) + } + } +} + +@Composable +private fun ProxyStatusItem( + text: String, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + textAlign = TextAlign.Center, + maxLines = 1, + modifier = Modifier.fillMaxWidth() + ) + } +} + +@Composable +private fun ProxyStatusDivider() { + Box( + modifier = Modifier + .fillMaxHeight() + .width(1.dp) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)) + ) +} diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/ui/ExternalLinks.kt b/app/src/main/java/com/amurcanov/tgwsproxy/ui/ExternalLinks.kt new file mode 100644 index 00000000..9ae76ae4 --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/ui/ExternalLinks.kt @@ -0,0 +1,63 @@ +package com.amurcanov.tgwsproxy.ui + +import android.content.Context +import android.content.Intent +import android.net.Uri + +private val browserPackages = listOf( + "com.android.chrome", + "com.google.android.googlequicksearchbox", + "org.mozilla.firefox", + "com.yandex.browser", + "ru.yandex.searchplugin", + "com.yandex.browser.lite", + "com.opera.browser", + "com.opera.mini.native", + "com.microsoft.emmx", + "com.brave.browser", + "com.duckduckgo.mobile.android", + "com.sec.android.app.sbrowser", + "com.vivaldi.browser", + "com.kiwibrowser.browser", +) + +private val browserProbeUri: Uri = Uri.parse("https://www.example.com") + +private fun createBrowserIntent(uri: Uri): Intent { + return Intent(Intent.ACTION_VIEW, uri).apply { + addCategory(Intent.CATEGORY_BROWSABLE) + } +} + +private fun resolveBrowserPackage(context: Context): String? { + val pm = context.packageManager + + for (pkg in browserPackages) { + val intent = createBrowserIntent(browserProbeUri).apply { + setPackage(pkg) + } + if (intent.resolveActivity(pm) != null) { + return pkg + } + } + + return pm.queryIntentActivities(createBrowserIntent(browserProbeUri), 0) + .firstOrNull() + ?.activityInfo + ?.packageName +} + +fun openUrlInBrowser(context: Context, url: String) { + try { + val pm = context.packageManager + val uri = Uri.parse(url) + val browserPackage = resolveBrowserPackage(context) ?: return + val intent = createBrowserIntent(uri).apply { + setPackage(browserPackage) + } + if (intent.resolveActivity(pm) != null) { + context.startActivity(intent) + } + } catch (_: Exception) { + } +} diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/ui/FloatingToolbar.kt b/app/src/main/java/com/amurcanov/tgwsproxy/ui/FloatingToolbar.kt new file mode 100644 index 00000000..f8964c31 --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/ui/FloatingToolbar.kt @@ -0,0 +1,297 @@ +package com.amurcanov.tgwsproxy.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +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.layout.onGloballyPositioned +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.amurcanov.tgwsproxy.R +import kotlin.math.roundToInt +import androidx.compose.ui.draw.scale +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.ui.draw.clip +import androidx.compose.foundation.border +import androidx.compose.ui.graphics.Color +import android.os.Build + +@Composable +fun FloatingToolbar( + currentTheme: String, + onThemeChange: (String) -> Unit, + isDynamicColor: Boolean, + onDynamicColorChange: (Boolean) -> Unit, + currentPalette: String, + onPaletteChange: (String) -> Unit, + modifier: Modifier = Modifier +) { + val configuration = LocalConfiguration.current + val density = LocalDensity.current + val screenHeightPx = remember(configuration.screenHeightDp, density) { + with(density) { configuration.screenHeightDp.dp.toPx() } + } + val screenWidthPx = remember(configuration.screenWidthDp, density) { + with(density) { configuration.screenWidthDp.dp.toPx() } + } + + var offsetY by rememberSaveable { mutableFloatStateOf(-1f) } + var isRightSide by rememberSaveable { mutableStateOf(true) } + var isExpanded by rememberSaveable { mutableStateOf(false) } + var tabHeightPx by remember { mutableFloatStateOf(0f) } + var panelHeightPx by remember { mutableFloatStateOf(0f) } + + val tabWidthDp = 42.dp + val tabHeightDp = 52.dp + val panelWidthDp = 220.dp + + val tabWidthPx = remember(density) { with(density) { tabWidthDp.toPx() } } + val fallbackTabHeightPx = remember(density) { with(density) { tabHeightDp.toPx() } } + val edgePaddingPx = remember(density) { with(density) { 8.dp.toPx() } } + val safeTopPx = WindowInsets.safeDrawing.getTop(density).toFloat() + val safeBottomPx = WindowInsets.safeDrawing.getBottom(density).toFloat() + val effectiveTabHeightPx = maxOf(tabHeightPx, fallbackTabHeightPx) + val floatingHeightPx = if (isExpanded && panelHeightPx > 0f) { + maxOf(effectiveTabHeightPx, panelHeightPx) + } else { + effectiveTabHeightPx + } + val minOffsetY = safeTopPx + edgePaddingPx + val maxOffsetY = (screenHeightPx - safeBottomPx - floatingHeightPx - edgePaddingPx) + .coerceAtLeast(minOffsetY) + val defaultOffsetY = (screenHeightPx * 0.24f).coerceIn(minOffsetY, maxOffsetY) + + val targetXPx = if (isRightSide) screenWidthPx - tabWidthPx else 0f + + val animatedTabXPx by animateFloatAsState( + targetValue = targetXPx, + animationSpec = spring(stiffness = Spring.StiffnessLow), + label = "tab_shift" + ) + + LaunchedEffect(minOffsetY, maxOffsetY) { + offsetY = if (offsetY < 0f) { + defaultOffsetY + } else { + offsetY.coerceIn(minOffsetY, maxOffsetY) + } + } + + Box(modifier = modifier.fillMaxSize()) { + Surface( + onClick = { isExpanded = !isExpanded }, + modifier = Modifier + .offset { IntOffset(animatedTabXPx.roundToInt(), offsetY.roundToInt()) } + .onGloballyPositioned { coordinates -> + tabHeightPx = coordinates.size.height.toFloat() + } + .pointerInput(minOffsetY, maxOffsetY) { + detectDragGestures( + onDrag = { change, dragAmount -> + change.consume() + offsetY = (offsetY + dragAmount.y).coerceIn(minOffsetY, maxOffsetY) + } + ) + }, + shape = if (isRightSide) + RoundedCornerShape(topStart = 14.dp, bottomStart = 14.dp) + else + RoundedCornerShape(topEnd = 14.dp, bottomEnd = 14.dp), + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f), + shadowElevation = 6.dp, + tonalElevation = 4.dp, + ) { + Box( + modifier = Modifier.size(tabWidthDp, tabHeightDp), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = R.drawable.ic_palette), + contentDescription = stringResource(R.string.theme), + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + + AnimatedVisibility( + visible = isExpanded, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.offset { + val panelWidthPx = with(density) { panelWidthDp.toPx() } + val gap = with(density) { 8.dp.toPx() } + val panelX = if (isRightSide) { + (targetXPx - panelWidthPx - gap).roundToInt() + } else { + (tabWidthPx + gap).roundToInt() + } + IntOffset(panelX, offsetY.roundToInt()) + } + ) { + Surface( + modifier = Modifier.onGloballyPositioned { coordinates -> + panelHeightPx = coordinates.size.height.toFloat() + }, + shape = RoundedCornerShape(32.dp), + color = MaterialTheme.colorScheme.surface, + shadowElevation = 8.dp, + tonalElevation = 4.dp, + ) { + Column( + modifier = Modifier.padding(12.dp).width(panelWidthDp - 24.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + stringResource(R.string.theme), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(start = 4.dp, bottom = 4.dp) + ) + + ThemeOption( + icon = R.drawable.ic_auto, + label = stringResource(R.string.theme_system), + selected = currentTheme == "system", + onClick = { onThemeChange("system"); isExpanded = false } + ) + ThemeOption( + icon = R.drawable.ic_light_mode, + label = stringResource(R.string.theme_light), + selected = currentTheme == "light", + onClick = { onThemeChange("light"); isExpanded = false } + ) + ThemeOption( + icon = R.drawable.ic_dark_mode, + label = stringResource(R.string.theme_dark), + selected = currentTheme == "dark", + onClick = { onThemeChange("dark"); isExpanded = false } + ) + + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + + val supportsDynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + val showDynamicColorOn = isDynamicColor && supportsDynamicColor + val showPalettes = !showDynamicColorOn + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + stringResource(R.string.dynamic_colors), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = if (supportsDynamicColor) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + Switch( + checked = showDynamicColorOn, + onCheckedChange = { onDynamicColorChange(it) }, + enabled = supportsDynamicColor, + modifier = Modifier.scale(0.8f) + ) + } + + AnimatedVisibility(visible = showPalettes) { + Column { + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + Text( + stringResource(R.string.palette), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 6.dp, start = 4.dp) + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + PaletteCircle("indigo", 0xFF5B588D, currentPalette, onPaletteChange) + PaletteCircle("forest", 0xFF5F5D68, currentPalette, onPaletteChange) + PaletteCircle("espresso", 0xFF6D4C41, currentPalette, onPaletteChange) + } + Spacer(modifier = Modifier.height(6.dp)) + } + } + } + } + } + } +} + +@Composable +private fun ThemeOption( + icon: Int, + label: String, + selected: Boolean, + onClick: () -> Unit +) { + Surface( + onClick = onClick, + shape = RoundedCornerShape(24.dp), + color = if (selected) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surface, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Icon( + painter = painterResource(id = icon), + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = if (selected) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, + color = if (selected) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface, + fontSize = 13.sp + ) + } + } +} +@Composable +fun PaletteCircle( + paletteId: String, + colorHex: Long, + selectedId: String, + onClick: (String) -> Unit +) { + val isSelected = paletteId == selectedId + Box( + modifier = Modifier + .size(30.dp) + .clip(CircleShape) + .background(Color(colorHex)) + .clickable { onClick(paletteId) } + .then( + if (isSelected) Modifier.border(3.dp, MaterialTheme.colorScheme.primary, CircleShape) + else Modifier + ) + ) +} diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/ui/InfoTab.kt b/app/src/main/java/com/amurcanov/tgwsproxy/ui/InfoTab.kt new file mode 100644 index 00000000..faf452a6 --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/ui/InfoTab.kt @@ -0,0 +1,1250 @@ +package com.amurcanov.tgwsproxy.ui + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.os.Build +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.GenericShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.automirrored.filled.HelpOutline +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Update +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.amurcanov.tgwsproxy.BuildConfig +import com.amurcanov.tgwsproxy.LogEntry +import com.amurcanov.tgwsproxy.LogManager +import com.amurcanov.tgwsproxy.R +import com.amurcanov.tgwsproxy.SettingsStore +import com.amurcanov.tgwsproxy.UPDATE_DIALOG_ACTION_POSTPONED +import com.amurcanov.tgwsproxy.UPDATE_DIALOG_ACTION_UPDATE +import com.amurcanov.tgwsproxy.fetchLatestReleaseInfo +import com.amurcanov.tgwsproxy.isNewerVersion +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.min +import kotlin.math.sin +import kotlinx.coroutines.launch + +private const val AndroidForkRepoUrl = "" +private const val AndroidForkIssuesUrl = "$AndroidForkRepoUrl/issues/new" +private const val DeveloperProfileUrl = "" +private const val OriginalProjectUrl = "" +private const val ProxyReferenceUrl = "" +private const val AndroidAppDonateUrl = "" +private const val OriginalIdeaDonateUrl = "" + +private val DonateActionButtonColor = Color(0xFF00AEA5) +private val OriginalIdeaDonateColor = Color(0xFFFF8A24) + +private val Android16BlobShape: Shape = GenericShape { size, _ -> + val centerX = size.width / 2f + val centerY = size.height / 2f + val outerRadius = min(size.width, size.height) / 2f + val innerRadius = outerRadius * 0.92f + val points = 14 + + for (i in 0 until points * 2) { + val angle = (-PI / 2.0) + (i * PI / points) + val radius = if (i % 2 == 0) outerRadius else innerRadius + val x = centerX + (radius * cos(angle)).toFloat() + val y = centerY + (radius * sin(angle)).toFloat() + if (i == 0) moveTo(x, y) else lineTo(x, y) + } + close() +} + +@Composable +fun InfoTab(settingsStore: SettingsStore) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var showHelpDialog by remember { mutableStateOf(false) } + var showDonateDialog by remember { mutableStateOf(false) } + var actionsExpanded by rememberSaveable { mutableStateOf(true) } + var projectExpanded by rememberSaveable { mutableStateOf(true) } + var isCheckingUpdates by remember { mutableStateOf(false) } + var pendingManualRelease by remember { mutableStateOf(null) } + val savedPort by settingsStore.port.collectAsStateWithLifecycle(initialValue = "1443") + val savedPoolSize by settingsStore.poolSize.collectAsStateWithLifecycle(initialValue = 4) + val savedCfEnabled by settingsStore.cfproxyEnabled.collectAsStateWithLifecycle(initialValue = true) + val savedCustomCfDomainEnabled by settingsStore.customCfDomainEnabled.collectAsStateWithLifecycle(initialValue = false) + val savedCustomCfDomain by settingsStore.customCfDomain.collectAsStateWithLifecycle(initialValue = "") + val updateLatestVersion by settingsStore.updateLatestVersion.collectAsStateWithLifecycle(initialValue = "") + val updateLastError by settingsStore.updateLastError.collectAsStateWithLifecycle(initialValue = "") + val currentLogs by LogManager.logs.collectAsStateWithLifecycle() + val currentVersion = remember { "v${BuildConfig.VERSION_NAME.removePrefix("v")}" } + val updateStatusSubtitle = when { + isCheckingUpdates -> stringResource(R.string.update_checking) + updateLatestVersion.isNotBlank() && isNewerVersion(currentVersion, updateLatestVersion) -> + stringResource(R.string.update_available_on_github, updateLatestVersion) + updateLatestVersion.isNotBlank() -> stringResource(R.string.update_latest_version, updateLatestVersion) + updateLastError.isNotBlank() -> stringResource(R.string.update_last_check_failed) + else -> stringResource(R.string.update_check_manually) + } + val reportText = buildSupportReport( + port = savedPort, + poolSize = savedPoolSize, + cfEnabled = savedCfEnabled, + customCfDomainEnabled = savedCustomCfDomainEnabled, + customCfDomain = savedCustomCfDomain, + logs = currentLogs, + context = context + ) + + Column( + modifier = Modifier + .fillMaxSize() + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 0.dp, bottom = 28.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.info), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface + ) + } + + InfoHeroCard(onSupportClick = { showDonateDialog = true }) + + ExpandableSectionCard( + title = stringResource(R.string.actions), + itemCount = stringResource(R.string.items_count, 4), + expanded = actionsExpanded, + onToggle = { actionsExpanded = !actionsExpanded }, + icon = { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + } + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + InfoActionTile( + title = stringResource(R.string.raise_issue), + subtitle = stringResource(R.string.open_github_issue), + modifier = Modifier.weight(1f), + onClick = { openUrlInBrowser(context, AndroidForkIssuesUrl) }, + icon = { + Icon( + painter = painterResource(id = R.drawable.ic_github), + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + ) + + InfoActionTile( + title = stringResource(R.string.build_report), + subtitle = stringResource(R.string.report_subtitle), + modifier = Modifier.weight(1f), + onClick = { + val clipboard = context.getSystemService(ClipboardManager::class.java) + clipboard?.setPrimaryClip(ClipData.newPlainText("TgWsProxy Report", reportText)) + Toast.makeText(context, context.getString(R.string.report_copied), Toast.LENGTH_SHORT).show() + }, + icon = { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + ) + } + + WideActionTile( + title = stringResource(R.string.help), + subtitle = stringResource(R.string.help_subtitle), + onClick = { showHelpDialog = true }, + icon = { + Icon( + imageVector = Icons.AutoMirrored.Filled.HelpOutline, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + ) + + WideActionTile( + title = stringResource(R.string.check_updates), + subtitle = updateStatusSubtitle, + onClick = { + if (isCheckingUpdates) return@WideActionTile + isCheckingUpdates = true + scope.launch { + val checkedAt = System.currentTimeMillis() + val release = fetchLatestReleaseInfo(currentVersion) + settingsStore.saveUpdateState( + lastCheckAt = checkedAt, + latestVersion = release?.versionTag ?: "", + error = if (release == null) context.getString(R.string.update_check_failed_short) else "" + ) + isCheckingUpdates = false + + if (release == null) { + val message = if (updateLatestVersion.isNotBlank()) { + context.getString(R.string.update_check_failed_known, updateLatestVersion) + } else { + context.getString(R.string.update_check_failed) + } + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + return@launch + } + + if (isNewerVersion(currentVersion, release.versionTag)) { + settingsStore.saveUpdateDialogShown(release.versionTag, checkedAt) + pendingManualRelease = release + } else { + Toast.makeText( + context, + context.getString(R.string.update_already_latest, release.versionTag), + Toast.LENGTH_SHORT + ).show() + } + } + }, + icon = { + Icon( + imageVector = Icons.Default.Update, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + ) + } + + ExpandableSectionCard( + title = stringResource(R.string.about_project), + itemCount = stringResource(R.string.links_count, 4), + expanded = projectExpanded, + onToggle = { projectExpanded = !projectExpanded }, + icon = { + Icon( + imageVector = Icons.Default.Code, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + } + ) { + ProjectLinkRow( + title = stringResource(R.string.android_author), + subtitle = stringResource(R.string.android_author_subtitle), + onClick = { openUrlInBrowser(context, DeveloperProfileUrl) }, + icon = { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + } + ) + + ProjectLinkRow( + title = stringResource(R.string.android_fork_repo), + subtitle = stringResource(R.string.android_fork_repo_subtitle), + onClick = { openUrlInBrowser(context, AndroidForkRepoUrl) }, + icon = { + Icon( + painter = painterResource(id = R.drawable.ic_github), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + } + ) + + ProjectLinkRow( + title = stringResource(R.string.original_tg_ws_proxy), + subtitle = stringResource(R.string.original_tg_ws_proxy_subtitle), + onClick = { openUrlInBrowser(context, OriginalProjectUrl) }, + icon = { + Icon( + imageVector = Icons.Default.Code, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + } + ) + + ProjectLinkRow( + title = stringResource(R.string.useful_material), + subtitle = stringResource(R.string.useful_material_subtitle), + onClick = { openUrlInBrowser(context, ProxyReferenceUrl) }, + icon = { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + } + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + + } + + if (showDonateDialog) { + DonateDialog(onDismiss = { showDonateDialog = false }) + } + + pendingManualRelease?.let { release -> + AppUpdateDialog( + release = release, + onPostpone = { + pendingManualRelease = null + Toast.makeText(context, context.getString(R.string.update_postponed_24h), Toast.LENGTH_SHORT).show() + scope.launch { + val now = System.currentTimeMillis() + settingsStore.saveUpdatePostpone( + version = release.versionTag, + until = now + 24L * 60L * 60L * 1000L + ) + settingsStore.saveUpdateDialogAction( + version = release.versionTag, + action = UPDATE_DIALOG_ACTION_POSTPONED, + actedAt = now + ) + } + }, + onUpdate = { + pendingManualRelease = null + scope.launch { + settingsStore.saveUpdateDialogAction( + version = release.versionTag, + action = UPDATE_DIALOG_ACTION_UPDATE, + actedAt = System.currentTimeMillis() + ) + openUrlInBrowser(context, release.releaseUrl) + } + } + ) + } + + if (showHelpDialog) { + Dialog( + onDismissRequest = { showHelpDialog = false }, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface( + shape = RoundedCornerShape(32.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 8.dp, + modifier = Modifier + .fillMaxWidth(0.95f) + .fillMaxHeight(0.85f) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 28.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + Spacer(Modifier.height(28.dp)) + + Text( + stringResource(R.string.help), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Black, + color = MaterialTheme.colorScheme.primary + ) + + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + HelpSection( + title = stringResource(R.string.help_auto_dc_title), + text = stringResource(R.string.help_auto_dc_text) + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + HelpSection( + title = "CloudFlare CDN", + text = stringResource(R.string.help_cloudflare_text) + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + HelpSection( + title = stringResource(R.string.ws_pool), + text = stringResource(R.string.help_ws_pool_text) + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + HelpSection( + title = stringResource(R.string.secret_key), + text = stringResource(R.string.help_secret_key_text) + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + HelpSection( + title = stringResource(R.string.experimental_mode), + text = stringResource(R.string.help_experimental_text) + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + HelpSection( + title = stringResource(R.string.autostart), + text = stringResource(R.string.help_autostart_text) + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + HelpSection( + title = stringResource(R.string.help_slow_connect_title), + text = stringResource(R.string.help_slow_connect_text) + ) + } + + Spacer(Modifier.height(8.dp)) + + Button( + onClick = { showHelpDialog = false }, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + shape = RoundedCornerShape(24.dp) + ) { + Text(stringResource(R.string.understood), fontWeight = FontWeight.Bold, fontSize = 16.sp) + } + + Spacer(Modifier.height(28.dp)) + } + } + } + } +} + +@Composable +private fun InfoHeroCard(onSupportClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + val isDark = colors.background.luminance() < 0.22f + val heroBrush = remember(colors.primaryContainer, colors.secondaryContainer, colors.surfaceVariant) { + Brush.linearGradient( + listOf( + colors.primaryContainer, + colors.secondaryContainer, + colors.surfaceVariant + ) + ) + } + val glassColor = if (isDark) { + colors.surface.copy(alpha = 0.46f) + } else { + Color.White.copy(alpha = 0.54f) + } + val glassBorder = colors.outlineVariant.copy(alpha = if (isDark) 0.50f else 0.32f) + val titleColor = if (isDark) colors.onSurface else colors.onSurface + val supportAccent = if (isDark) DonateActionButtonColor.copy(alpha = 0.92f) else DonateActionButtonColor + + Surface( + shape = RoundedCornerShape(32.dp), + color = Color.Transparent, + shadowElevation = 10.dp, + tonalElevation = 0.dp + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(32.dp)) + .background(heroBrush) + .padding(22.dp) + ) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .offset(x = 30.dp, y = (-34).dp) + .size(138.dp) + .clip(Android16BlobShape) + .background(colors.primary.copy(alpha = 0.10f)) + ) + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .offset(x = 26.dp, y = 30.dp) + .size(112.dp) + .clip(Android16BlobShape) + .background(colors.secondary.copy(alpha = 0.12f)) + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(18.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + HeroMetaPill( + text = "Amurcanov Fork", + containerColor = glassColor, + borderColor = glassBorder, + modifier = Modifier.weight(1f) + ) + HeroMetaPill( + text = "Flowseal Base", + containerColor = colors.primary.copy(alpha = if (isDark) 0.18f else 0.10f), + borderColor = colors.primary.copy(alpha = if (isDark) 0.22f else 0.14f), + modifier = Modifier.weight(1f) + ) + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Telegram WS Proxy", + style = MaterialTheme.typography.headlineSmall.copy( + fontWeight = FontWeight.Black, + fontSize = 30.sp, + lineHeight = 34.sp + ), + color = titleColor + ) + Text( + text = stringResource(R.string.hero_description), + style = MaterialTheme.typography.bodyMedium, + color = colors.onSurfaceVariant, + lineHeight = 21.sp + ) + } + + Button( + onClick = onSupportClick, + modifier = Modifier + .fillMaxWidth() + .height(54.dp), + shape = RoundedCornerShape(22.dp), + colors = ButtonDefaults.buttonColors( + containerColor = supportAccent, + contentColor = Color.White + ) + ) { + Icon( + imageVector = Icons.Default.Favorite, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = stringResource(R.string.support_development), + fontWeight = FontWeight.Bold, + fontSize = 15.sp + ) + } + } + } + } +} + +@Composable +private fun HeroMetaPill( + text: String, + containerColor: Color, + borderColor: Color, + modifier: Modifier = Modifier, + icon: (@Composable () -> Unit)? = null +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(18.dp), + color = containerColor, + border = BorderStroke(1.dp, borderColor) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + icon?.invoke() + if (icon != null) { + Spacer(modifier = Modifier.width(8.dp)) + } + Text( + text = text, + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + } + } +} + +@Composable +private fun ExpandableSectionCard( + title: String, + itemCount: String, + expanded: Boolean, + onToggle: () -> Unit, + icon: @Composable () -> Unit, + content: @Composable ColumnScope.() -> Unit +) { + val arrowRotation by animateFloatAsState( + targetValue = if (expanded) 180f else 0f, + label = "section_arrow_rotation" + ) + + AppSectionCard( + contentPadding = PaddingValues(horizontal = 18.dp, vertical = 18.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .clickable(onClick = onToggle) + .padding(vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.primaryContainer + ) { + Box( + modifier = Modifier.size(40.dp), + contentAlignment = Alignment.Center + ) { + icon() + } + } + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + } + + MetaChip(text = itemCount) + + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .size(24.dp) + .rotate(arrowRotation) + ) + } + + AnimatedVisibility( + visible = expanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.30f)) + content() + } + } + } +} + +@Composable +private fun MetaChip(text: String) { + Surface( + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.88f) + ) { + Text( + text = text, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun InfoActionTile( + title: String, + subtitle: String, + modifier: Modifier = Modifier, + onClick: () -> Unit, + icon: @Composable () -> Unit +) { + Surface( + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.70f), + modifier = modifier + .clip(RoundedCornerShape(24.dp)) + .clickable(onClick = onClick) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 116.dp) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.primaryContainer + ) { + Box( + modifier = Modifier.size(40.dp), + contentAlignment = Alignment.Center + ) { + icon() + } + } + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 18.sp + ) + } + } + } +} + +@Composable +private fun WideActionTile( + title: String, + subtitle: String, + onClick: () -> Unit, + icon: @Composable () -> Unit +) { + Surface( + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.70f), + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .clickable(onClick = onClick) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 15.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.primaryContainer + ) { + Box( + modifier = Modifier.size(40.dp), + contentAlignment = Alignment.Center + ) { + icon() + } + } + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(3.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 18.sp + ) + } + + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + } + } +} + +@Composable +private fun ProjectLinkRow( + title: String, + subtitle: String, + onClick: () -> Unit, + icon: @Composable () -> Unit +) { + Surface( + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.64f), + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .clickable(onClick = onClick) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.primaryContainer + ) { + Box( + modifier = Modifier.defaultMinSize(minWidth = 40.dp, minHeight = 40.dp), + contentAlignment = Alignment.Center + ) { + icon() + } + } + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 18.sp + ) + } + + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + } + } +} + +@Composable +private fun SupportAccentCard(onClick: () -> Unit) { + val colors = MaterialTheme.colorScheme + + Surface( + shape = RoundedCornerShape(32.dp), + color = colors.secondaryContainer.copy(alpha = 0.94f), + shadowElevation = 10.dp, + tonalElevation = 2.dp + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp) + ) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .offset(x = 18.dp, y = (-20).dp) + .size(88.dp) + .clip(Android16BlobShape) + .background(colors.primary.copy(alpha = 0.09f)) + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(14.dp) + ) { + Surface( + shape = RoundedCornerShape(16.dp), + color = colors.surface.copy(alpha = 0.88f) + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.Favorite, + contentDescription = null, + tint = DonateActionButtonColor, + modifier = Modifier.size(16.dp) + ) + Text( + text = stringResource(R.string.support_project), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = colors.onSurface + ) + } + } + + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = stringResource(R.string.support_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Black, + color = colors.onSurface + ) + Text( + text = stringResource(R.string.support_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = colors.onSurfaceVariant, + lineHeight = 21.sp + ) + } + + Button( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .height(54.dp), + shape = RoundedCornerShape(24.dp), + colors = ButtonDefaults.buttonColors( + containerColor = DonateActionButtonColor, + contentColor = Color.White + ) + ) { + Icon( + imageVector = Icons.Default.Favorite, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.open_donation_options), + fontWeight = FontWeight.Bold, + fontSize = 15.sp + ) + } + } + } + } +} + +private fun buildSupportReport( + port: String, + poolSize: Int, + cfEnabled: Boolean, + customCfDomainEnabled: Boolean, + customCfDomain: String, + logs: List, + context: Context +): String { + val androidVersion = Build.VERSION.RELEASE ?: "?" + val sdkInt = Build.VERSION.SDK_INT + val primaryAbi = Build.SUPPORTED_ABIS.firstOrNull().orEmpty().ifBlank { "unknown" } + val supportedAbis = Build.SUPPORTED_ABIS.joinToString().ifBlank { "unknown" } + val supported32Abis = Build.SUPPORTED_32_BIT_ABIS.joinToString().ifBlank { "none" } + val supported64Abis = Build.SUPPORTED_64_BIT_ABIS.joinToString().ifBlank { "none" } + val manufacturer = Build.MANUFACTURER.orEmpty().ifBlank { "unknown" } + val brand = Build.BRAND.orEmpty().ifBlank { "unknown" } + val model = Build.MODEL.orEmpty().ifBlank { "unknown" } + val device = Build.DEVICE.orEmpty().ifBlank { "unknown" } + val product = Build.PRODUCT.orEmpty().ifBlank { "unknown" } + val hardware = Build.HARDWARE.orEmpty().ifBlank { "unknown" } + val board = Build.BOARD.orEmpty().ifBlank { "unknown" } + val romDisplay = Build.DISPLAY.orEmpty().ifBlank { "unknown" } + val buildId = Build.ID.orEmpty().ifBlank { "unknown" } + val buildFingerprint = Build.FINGERPRINT.orEmpty().ifBlank { "unknown" } + val buildType = Build.TYPE.orEmpty().ifBlank { "unknown" } + val socManufacturer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + Build.SOC_MANUFACTURER.orEmpty().ifBlank { "unknown" } + } else { + "n/a" + } + val socModel = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + Build.SOC_MODEL.orEmpty().ifBlank { "unknown" } + } else { + "n/a" + } + val mode = if (cfEnabled) "Cloudflare" else context.getString(R.string.report_mode_direct) + val cfDomainLine = if (cfEnabled && customCfDomainEnabled && customCfDomain.isNotBlank()) { + "\n${context.getString(R.string.report_cf_domain, customCfDomain.trim())}" + } else { + "" + } + + val recentErrors = logs + .asReversed() + .filter { it.priority >= 5 } + .take(10) + + val errorsBlock = if (recentErrors.isEmpty()) { + context.getString(R.string.none_lower) + } else { + recentErrors.joinToString("\n") { entry -> + val level = when (entry.priority) { + 6 -> "ERROR" + 5 -> "WARN" + else -> "INFO" + } + "- [$level] ${entry.message}${if (entry.count > 1) " (x${entry.count})" else ""}" + } + } + + return buildString { + appendLine(context.getString(R.string.report_app_version, BuildConfig.VERSION_NAME)) + appendLine(context.getString(R.string.report_android, androidVersion, sdkInt)) + appendLine(context.getString(R.string.report_device, manufacturer, brand, model)) + appendLine(context.getString(R.string.report_device_code, device)) + appendLine(context.getString(R.string.report_product, product)) + appendLine("ABI: $primaryAbi") + appendLine(context.getString(R.string.report_all_abi, supportedAbis)) + appendLine("32-bit ABI: $supported32Abis") + appendLine("64-bit ABI: $supported64Abis") + appendLine("SoC: $socManufacturer / $socModel") + appendLine("Hardware: $hardware") + appendLine("Board: $board") + appendLine("ROM: $romDisplay") + appendLine("Build ID: $buildId") + appendLine("Build type: $buildType") + appendLine("Fingerprint: $buildFingerprint") + appendLine(context.getString(R.string.report_settings)) + appendLine(context.getString(R.string.report_mode, mode)) + appendLine(context.getString(R.string.report_ws_pool, poolSize)) + append(context.getString(R.string.report_port, port.trim().ifBlank { "1443" })) + append(cfDomainLine) + appendLine() + appendLine() + appendLine(context.getString(R.string.report_recent_errors)) + append(errorsBlock) + }.trim() +} + +@Composable +private fun DonateDialog( + onDismiss: () -> Unit +) { + val context = LocalContext.current + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface( + shape = RoundedCornerShape(32.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 10.dp, + shadowElevation = 14.dp, + modifier = Modifier.fillMaxWidth(0.92f) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 22.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + text = stringResource(R.string.donate_developers), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Black, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + FilledTonalIconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.close) + ) + } + } + + DonateSection( + title = stringResource(R.string.donate_android_author), + buttonColor = AppColors.donate, + onClick = { openUrlInBrowser(context, AndroidAppDonateUrl) } + ) { + Icon( + painter = painterResource(id = R.drawable.ic_yoomoney), + contentDescription = "ЮMoney", + tint = Color.Unspecified, + modifier = Modifier + .width(126.dp) + .height(28.dp) + ) + } + + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f)) + + DonateSection( + title = stringResource(R.string.donate_original_author), + buttonColor = OriginalIdeaDonateColor, + onClick = { openUrlInBrowser(context, OriginalIdeaDonateUrl) } + ) { + Icon( + painter = painterResource(id = R.drawable.ic_crypto_wordmark), + contentDescription = "Crypto", + tint = Color.Unspecified, + modifier = Modifier + .width(138.dp) + .height(24.dp) + ) + } + } + } + } +} + +@Composable +private fun DonateSection( + title: String, + buttonColor: Color, + onClick: () -> Unit, + buttonContent: @Composable RowScope.() -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(14.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + + Button( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .height(62.dp), + shape = RoundedCornerShape(22.dp), + colors = ButtonDefaults.buttonColors( + containerColor = buttonColor, + contentColor = Color.White + ) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + content = buttonContent + ) + } + } +} + +@Composable +private fun HelpSection(title: String, text: String) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + Text( + text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 20.sp + ) + } +} diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/ui/LogsTab.kt b/app/src/main/java/com/amurcanov/tgwsproxy/ui/LogsTab.kt new file mode 100644 index 00000000..191948c9 --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/ui/LogsTab.kt @@ -0,0 +1,244 @@ +package com.amurcanov.tgwsproxy.ui + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.amurcanov.tgwsproxy.LogEntry +import com.amurcanov.tgwsproxy.LogManager +import com.amurcanov.tgwsproxy.SettingsStore +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LogsTab(settingsStore: SettingsStore) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val currentLogs by LogManager.logs.collectAsStateWithLifecycle() + + val savedInfo by settingsStore.logShowInfo.collectAsStateWithLifecycle(initialValue = SettingsStore.DEFAULT_LOG_SHOW_INFO) + val savedError by settingsStore.logShowError.collectAsStateWithLifecycle(initialValue = SettingsStore.DEFAULT_LOG_SHOW_ERROR) + val savedNull by settingsStore.logShowNull.collectAsStateWithLifecycle(initialValue = false) + + val nullLogsDisabled = stringResource(com.amurcanov.tgwsproxy.R.string.null_logs_disabled) + val filteredLogs = remember(currentLogs, savedInfo, savedError, savedNull, nullLogsDisabled) { + if (savedNull) { + listOf(LogEntry( + key = "null_msg", + message = nullLogsDisabled, + count = 1, + isError = false, + priority = 4, + isEssential = true + )) + } else { + currentLogs.filter { entry -> + entry.isEssential || + (savedInfo && entry.priority == 4) || + (savedError && entry.priority >= 5) + } + } + } + + val listState = rememberLazyListState() + var hasInitialScrolled by remember { mutableStateOf(false) } + + // Auto-scroll logic + LaunchedEffect(filteredLogs.size) { + if (filteredLogs.isNotEmpty()) { + if (!hasInitialScrolled) { + // Absolute instant jump on first appearance + listState.scrollToItem(filteredLogs.size - 1) + hasInitialScrolled = true + } else { + // Smooth scroll only for new incoming logs + listState.animateScrollToItem(filteredLogs.size - 1) + } + } + } + + Column(modifier = Modifier.fillMaxSize().padding(start = 16.dp, end = 16.dp, top = 0.dp, bottom = 12.dp)) { + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + stringResource(com.amurcanov.tgwsproxy.R.string.event_log), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface + ) + Row { + IconButton(onClick = { LogManager.clearLogs() }) { + Icon(Icons.Default.Delete, contentDescription = stringResource(com.amurcanov.tgwsproxy.R.string.clear), tint = MaterialTheme.colorScheme.primary) + } + IconButton(onClick = { + val text = filteredLogs.joinToString("\n") { "${it.message} (x${it.count})" } + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("TgWsProxy Logs", text) + clipboard.setPrimaryClip(clip) + Toast.makeText(context, context.getString(com.amurcanov.tgwsproxy.R.string.copied), Toast.LENGTH_SHORT).show() + }) { + Icon(Icons.Default.ContentCopy, contentDescription = stringResource(com.amurcanov.tgwsproxy.R.string.copy), tint = MaterialTheme.colorScheme.primary) + } + } + } + + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + LogFilterChip("INFO", savedInfo && !savedNull, true, modifier = Modifier.weight(1f)) { + scope.launch { settingsStore.saveLogFilters(false, !savedInfo, savedError, false) } + } + LogFilterChip("ERROR", savedError && !savedNull, true, modifier = Modifier.weight(1f)) { + scope.launch { settingsStore.saveLogFilters(false, savedInfo, !savedError, false) } + } + LogFilterChip("NULL", savedNull, true, modifier = Modifier.weight(1f)) { + scope.launch { settingsStore.saveLogFilters(false, false, false, !savedNull) } + } + } + val isDark = isSystemInDarkTheme() + val terminalBg = if (isDark) AppColors.terminalBgDark else AppColors.terminalBg + + Box( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(24.dp)) + .background(terminalBg) + ) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize().padding(16.dp), + contentPadding = PaddingValues(bottom = 12.dp) + ) { + items( + items = filteredLogs, + key = { it.key } + ) { entry -> + LogLine(entry) + } + } + } + } +} + +@Composable +private fun LogFilterChip( + label: String, + selected: Boolean, + enabled: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + Button( + onClick = onClick, + enabled = enabled, + shape = RoundedCornerShape(24.dp), + modifier = modifier.height(52.dp), + contentPadding = PaddingValues(0.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, + contentColor = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface, + disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) + ) + ) { + Text( + label, + style = MaterialTheme.typography.labelSmall, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium + ) + } +} + +@Composable +private fun LogLine(entry: LogEntry) { + val color = when (entry.priority) { + 6 -> AppColors.terminalRed + 5 -> AppColors.terminalOrange + 4 -> AppColors.terminalGreen + 3 -> AppColors.terminalBlue + else -> AppColors.terminalText + } + + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Count badge + Surface( + color = AppColors.terminalCounter.copy(alpha = 0.2f), + shape = RoundedCornerShape(12.dp), + modifier = Modifier.defaultMinSize(minWidth = 22.dp, minHeight = 22.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.padding(horizontal = 5.dp) + ) { + Text( + text = "${entry.count}", + color = AppColors.terminalBlue, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + maxLines = 1 + ) + } + } + + Spacer(modifier = Modifier.width(6.dp)) + + val icon = when (entry.priority) { + 6 -> Icons.Default.Error + 5 -> Icons.Default.Warning + 4 -> Icons.Default.Info + 3 -> Icons.Default.BugReport + else -> Icons.Default.Info + } + + Icon( + imageVector = icon, + contentDescription = null, + tint = color.copy(alpha = 0.8f), + modifier = Modifier.size(14.dp) + ) + + Spacer(modifier = Modifier.width(6.dp)) + + Text( + text = entry.message, + color = color, + fontSize = 13.sp, + fontFamily = FontFamily.Monospace, + fontWeight = if (entry.isError) FontWeight.Bold else FontWeight.Normal, + lineHeight = 17.sp, + modifier = Modifier.weight(1f) + ) + } +} diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/ui/SettingsTab.kt b/app/src/main/java/com/amurcanov/tgwsproxy/ui/SettingsTab.kt new file mode 100644 index 00000000..52216c76 --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/ui/SettingsTab.kt @@ -0,0 +1,560 @@ +package com.amurcanov.tgwsproxy.ui + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.ActivityNotFoundException +import android.net.Uri +import android.widget.Toast +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +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.Cloud +import androidx.compose.material.icons.filled.PowerSettingsNew +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.VpnKey +import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.Layers +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.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.amurcanov.tgwsproxy.ProxyService +import com.amurcanov.tgwsproxy.SettingsStore +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private fun generateRandomSecret(): String { + val bytes = ByteArray(16) + java.security.SecureRandom().nextBytes(bytes) + return bytes.joinToString("") { "%02x".format(it) } +} + +fun openTelegram(context: Context, url: String) { + try { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } catch (_: ActivityNotFoundException) { + Toast.makeText( + context, + com.amurcanov.tgwsproxy.R.string.telegram_not_found, + Toast.LENGTH_SHORT + ).show() + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsTab(settingsStore: SettingsStore) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val isRunning by ProxyService.isRunning.collectAsStateWithLifecycle() + + val isReady by settingsStore.isReady.collectAsStateWithLifecycle(initialValue = false) + val isExperimental by settingsStore.isExperimentalMode.collectAsStateWithLifecycle(initialValue = false) + + val savedIsDcAuto by settingsStore.isDcAuto.collectAsStateWithLifecycle(initialValue = true) + val savedDc1 by settingsStore.dc1.collectAsStateWithLifecycle(initialValue = "") + val savedDc2 by settingsStore.dc2.collectAsStateWithLifecycle(initialValue = SettingsStore.DEFAULT_DIRECT_DC2_IP) + val savedDc3 by settingsStore.dc3.collectAsStateWithLifecycle(initialValue = "") + val savedDc4 by settingsStore.dc4.collectAsStateWithLifecycle(initialValue = SettingsStore.DEFAULT_DIRECT_DC4_IP) + val savedDc5 by settingsStore.dc5.collectAsStateWithLifecycle(initialValue = "") + val savedDc203 by settingsStore.dc203.collectAsStateWithLifecycle(initialValue = "") + val savedDc1m by settingsStore.dc1m.collectAsStateWithLifecycle(initialValue = "") + val savedDc2m by settingsStore.dc2m.collectAsStateWithLifecycle(initialValue = "") + val savedDc3m by settingsStore.dc3m.collectAsStateWithLifecycle(initialValue = "") + val savedDc4m by settingsStore.dc4m.collectAsStateWithLifecycle(initialValue = "") + val savedDc5m by settingsStore.dc5m.collectAsStateWithLifecycle(initialValue = "") + val savedDc203m by settingsStore.dc203m.collectAsStateWithLifecycle(initialValue = "") + val savedPort by settingsStore.port.collectAsStateWithLifecycle(initialValue = "1443") + val savedPoolSize by settingsStore.poolSize.collectAsStateWithLifecycle(initialValue = 4) + val savedCfEnabled by settingsStore.cfproxyEnabled.collectAsStateWithLifecycle(initialValue = true) + val savedCustomDomainEnabled by settingsStore.customCfDomainEnabled.collectAsStateWithLifecycle(initialValue = false) + val savedCustomDomain by settingsStore.customCfDomain.collectAsStateWithLifecycle(initialValue = "") + val autoStartOnBoot by settingsStore.autoStartOnBoot.collectAsStateWithLifecycle(initialValue = false) + val savedSecretKey by settingsStore.secretKey.collectAsStateWithLifecycle(initialValue = "LOADING") + + if (!isReady) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + } + return + } + + var isDcAuto by rememberSaveable(savedIsDcAuto) { mutableStateOf(savedIsDcAuto) } + var experimentalMode by rememberSaveable(isExperimental) { mutableStateOf(isExperimental) } + var dc1Text by rememberSaveable(savedDc1) { mutableStateOf(savedDc1) } + var dc2Text by rememberSaveable(savedDc2) { mutableStateOf(savedDc2) } + var dc3Text by rememberSaveable(savedDc3) { mutableStateOf(savedDc3) } + var dc4Text by rememberSaveable(savedDc4) { mutableStateOf(savedDc4) } + var dc5Text by rememberSaveable(savedDc5) { mutableStateOf(savedDc5) } + var dc203Text by rememberSaveable(savedDc203) { mutableStateOf(savedDc203) } + var dc1mText by rememberSaveable(savedDc1m) { mutableStateOf(savedDc1m) } + var dc2mText by rememberSaveable(savedDc2m) { mutableStateOf(savedDc2m) } + var dc3mText by rememberSaveable(savedDc3m) { mutableStateOf(savedDc3m) } + var dc4mText by rememberSaveable(savedDc4m) { mutableStateOf(savedDc4m) } + var dc5mText by rememberSaveable(savedDc5m) { mutableStateOf(savedDc5m) } + var dc203mText by rememberSaveable(savedDc203m) { mutableStateOf(savedDc203m) } + + var portText by rememberSaveable(savedPort) { mutableStateOf(savedPort) } + var selectedPoolSize by rememberSaveable(savedPoolSize) { mutableIntStateOf(savedPoolSize) } + var cfEnabled by rememberSaveable(savedCfEnabled) { mutableStateOf(savedCfEnabled) } + var customCfDomainEnabled by rememberSaveable(savedCustomDomainEnabled) { mutableStateOf(savedCustomDomainEnabled) } + var customCfDomain by rememberSaveable(savedCustomDomain) { mutableStateOf(savedCustomDomain) } + var secretKeyText by remember(savedSecretKey) { mutableStateOf(if (savedSecretKey == "LOADING") "" else savedSecretKey) } + + LaunchedEffect(savedSecretKey) { + if (savedSecretKey == "") { + val generated = generateRandomSecret() + secretKeyText = generated + settingsStore.saveSecretKey(generated) + } else if (savedSecretKey != "LOADING") { + secretKeyText = savedSecretKey + } + } + + var saveJob by remember { mutableStateOf(null) } + + fun scheduleSave() { + saveJob?.cancel() + saveJob = scope.launch { + delay(300) + settingsStore.saveAll( + isDcAuto, dc1Text, dc2Text, dc3Text, dc4Text, dc5Text, dc203Text, + dc1mText, dc2mText, dc3mText, dc4mText, dc5mText, dc203mText, + experimentalMode, portText, selectedPoolSize, + cfEnabled, customCfDomainEnabled, customCfDomain, secretKeyText + ) + } + } + + var showIpSetupDialog by rememberSaveable { mutableStateOf(false) } + val scrollState = rememberScrollState() + + if (showIpSetupDialog) { + IpSetupDialog( + isExperimental = experimentalMode, + onExperimentalChange = { experimentalMode = it; scheduleSave() }, + dc1Text = dc1Text, onDc1Change = { dc1Text = it; scheduleSave() }, + dc2Text = dc2Text, onDc2Change = { dc2Text = it; scheduleSave() }, + dc3Text = dc3Text, onDc3Change = { dc3Text = it; scheduleSave() }, + dc4Text = dc4Text, onDc4Change = { dc4Text = it; scheduleSave() }, + dc5Text = dc5Text, onDc5Change = { dc5Text = it; scheduleSave() }, + dc203Text = dc203Text, onDc203Change = { dc203Text = it; scheduleSave() }, + dc1mText = dc1mText, onDc1mChange = { dc1mText = it; scheduleSave() }, + dc2mText = dc2mText, onDc2mChange = { dc2mText = it; scheduleSave() }, + dc3mText = dc3mText, onDc3mChange = { dc3mText = it; scheduleSave() }, + dc4mText = dc4mText, onDc4mChange = { dc4mText = it; scheduleSave() }, + dc5mText = dc5mText, onDc5mChange = { dc5mText = it; scheduleSave() }, + dc203mText = dc203mText, onDc203mChange = { dc203mText = it; scheduleSave() }, + onDismiss = { showIpSetupDialog = false } + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(start = 16.dp, end = 16.dp, top = 0.dp, bottom = 12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(com.amurcanov.tgwsproxy.R.string.settings), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + AppSectionCard { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Default.Public, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp)) + Text( + stringResource(com.amurcanov.tgwsproxy.R.string.connection), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold + ) + } + OutlinedTextField( + value = portText, + onValueChange = { portText = it; scheduleSave() }, + label = { Text(stringResource(com.amurcanov.tgwsproxy.R.string.port)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + modifier = Modifier.fillMaxWidth().height(56.dp), + shape = RoundedCornerShape(24.dp), + textStyle = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ) + ) + OutlinedButton( + onClick = { showIpSetupDialog = true }, + enabled = !cfEnabled && !isRunning, + modifier = Modifier.fillMaxWidth().height(48.dp), + shape = RoundedCornerShape(24.dp), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + contentColor = MaterialTheme.colorScheme.primary, + disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = if (cfEnabled || isRunning) 0.2f else 0.5f)) + ) { + Icon(Icons.Default.Settings, null, Modifier.size(20.dp)) + if (cfEnabled) { + Spacer(Modifier.width(8.dp)) + Text(stringResource(com.amurcanov.tgwsproxy.R.string.auto_cf_enabled), fontWeight = FontWeight.SemiBold) + } else { + Spacer(Modifier.width(8.dp)) + Text(stringResource(com.amurcanov.tgwsproxy.R.string.configure_dc_addresses), fontWeight = FontWeight.SemiBold) + } + } + } + + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Default.Layers, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp)) + Text( + stringResource(com.amurcanov.tgwsproxy.R.string.ws_pool), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + val poolOptions = listOf(2, 4, 6) + poolOptions.forEach { size -> + PoolChip( + label = "$size", + selected = selectedPoolSize == size, + enabled = !isRunning, + modifier = Modifier.weight(1f).height(48.dp) + ) { + selectedPoolSize = size + scheduleSave() + } + } + } + } + + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Default.VpnKey, null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Text( + stringResource(com.amurcanov.tgwsproxy.R.string.secret_key), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold + ) + } + OutlinedTextField( + value = secretKeyText, + onValueChange = {}, + readOnly = true, + singleLine = true, + modifier = Modifier.fillMaxWidth().height(56.dp), + shape = RoundedCornerShape(24.dp), + textStyle = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), + trailingIcon = { + IconButton( + onClick = { + val newKey = generateRandomSecret() + secretKeyText = newKey + scope.launch { settingsStore.saveSecretKey(newKey) } + scheduleSave() + }, + enabled = !isRunning + ) { + Icon(Icons.Default.Refresh, null, tint = MaterialTheme.colorScheme.primary) + } + }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ) + ) + } + + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Default.Cloud, null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Text( + "CloudFlare CDN", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold + ) + } + Switch( + checked = cfEnabled, + onCheckedChange = { + cfEnabled = it + isDcAuto = it + scheduleSave() + }, + enabled = !isRunning + ) + } + + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Default.PowerSettingsNew, null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Text( + stringResource(com.amurcanov.tgwsproxy.R.string.autostart), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold + ) + } + Switch( + checked = autoStartOnBoot, + onCheckedChange = { enabled -> + scope.launch { settingsStore.saveAutoStartOnBoot(enabled) } + } + ) + } + } + + Spacer(Modifier.height(12.dp)) + } +} + +@Composable +private fun PoolChip( + label: String, + selected: Boolean, + enabled: Boolean = true, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + Button( + onClick = onClick, + enabled = enabled, + shape = RoundedCornerShape(24.dp), + modifier = modifier.height(48.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, + contentColor = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface + ) + ) { + Text( + label, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium + ) + } +} + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun IpSetupDialog( + isExperimental: Boolean, + onExperimentalChange: (Boolean) -> Unit, + dc1Text: String, onDc1Change: (String) -> Unit, + dc2Text: String, onDc2Change: (String) -> Unit, + dc3Text: String, onDc3Change: (String) -> Unit, + dc4Text: String, onDc4Change: (String) -> Unit, + dc5Text: String, onDc5Change: (String) -> Unit, + dc203Text: String, onDc203Change: (String) -> Unit, + dc1mText: String, onDc1mChange: (String) -> Unit, + dc2mText: String, onDc2mChange: (String) -> Unit, + dc3mText: String, onDc3mChange: (String) -> Unit, + dc4mText: String, onDc4mChange: (String) -> Unit, + dc5mText: String, onDc5mChange: (String) -> Unit, + dc203mText: String, onDc203mChange: (String) -> Unit, + onDismiss: () -> Unit +) { + val onIpChange = { newValue: String, update: (String) -> Unit -> + if (newValue.all { it.isDigit() || it == '.' }) { + update(newValue) + } + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface( + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 8.dp, + modifier = Modifier + .fillMaxWidth(0.95f) + .wrapContentHeight() + .heightIn(max = 560.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + stringResource(com.amurcanov.tgwsproxy.R.string.dc_addresses), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + + @Composable + fun dcInput(label: String, value: String, update: (String) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + label, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary + ) + OutlinedTextField( + value = value, + onValueChange = { onIpChange(it, update) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + modifier = Modifier.fillMaxWidth().height(52.dp), + shape = RoundedCornerShape(24.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ) + ) + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (isExperimental) { + dcInput("DC1", dc1Text, onDc1Change) + dcInput("DC2", dc2Text, onDc2Change) + dcInput("DC3", dc3Text, onDc3Change) + dcInput("DC4", dc4Text, onDc4Change) + dcInput("DC5", dc5Text, onDc5Change) + dcInput("DC203", dc203Text, onDc203Change) + + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + + Text(stringResource(com.amurcanov.tgwsproxy.R.string.media_dcs), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + + dcInput("DC1m", dc1mText, onDc1mChange) + dcInput("DC2m", dc2mText, onDc2mChange) + dcInput("DC3m", dc3mText, onDc3mChange) + dcInput("DC4m", dc4mText, onDc4mChange) + dcInput("DC5m", dc5mText, onDc5mChange) + dcInput("DC203m", dc203mText, onDc203mChange) + } else { + dcInput("DC2", dc2Text, onDc2Change) + dcInput("DC4", dc4Text, onDc4Change) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + stringResource(com.amurcanov.tgwsproxy.R.string.experimental_mode), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold + ) + Switch( + checked = isExperimental, + onCheckedChange = onExperimentalChange + ) + } + + Button( + onClick = onDismiss, + modifier = Modifier.fillMaxWidth().height(48.dp), + shape = RoundedCornerShape(24.dp) + ) { + Text(stringResource(com.amurcanov.tgwsproxy.R.string.done), fontWeight = FontWeight.SemiBold) + } + } + } + } +} diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/ui/Theme.kt b/app/src/main/java/com/amurcanov/tgwsproxy/ui/Theme.kt new file mode 100644 index 00000000..0d93fa1c --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/ui/Theme.kt @@ -0,0 +1,282 @@ +package com.amurcanov.tgwsproxy.ui + +import android.os.Build +import android.app.Activity +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import com.amurcanov.tgwsproxy.R + +// ═══ Inter Font Family ═══ +val InterFontFamily = FontFamily( + Font(R.font.inter_regular, FontWeight.Normal), + Font(R.font.inter_medium, FontWeight.Medium), + Font(R.font.inter_semibold, FontWeight.SemiBold), + Font(R.font.inter_bold, FontWeight.Bold), +) + +// ═══ Типография на Inter ═══ +val TgWsProxyTypography = Typography( + displayLarge = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Bold, fontSize = 57.sp, lineHeight = 64.sp), + displayMedium = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Bold, fontSize = 45.sp, lineHeight = 52.sp), + displaySmall = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Bold, fontSize = 36.sp, lineHeight = 44.sp), + headlineLarge = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 32.sp, lineHeight = 40.sp), + headlineMedium = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 28.sp, lineHeight = 36.sp), + headlineSmall = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 24.sp, lineHeight = 32.sp), + titleLarge = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 22.sp, lineHeight = 28.sp), + titleMedium = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp), + titleSmall = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.1.sp), + bodyLarge = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp, letterSpacing = 0.5.sp), + bodyMedium = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.25.sp), + bodySmall = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.4.sp), + labelLarge = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.1.sp), + labelMedium = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.5.sp), + labelSmall = TextStyle(fontFamily = InterFontFamily, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 16.sp, letterSpacing = 0.5.sp), +) + +// ═══ Светлая палитра — «Раф на кокосовом молоке» ═══ +private val LightColorScheme = lightColorScheme( + primary = Color(0xFF6D4C41), + onPrimary = Color(0xFFFFFFFF), + primaryContainer = Color(0xFFD7CCC8), + onPrimaryContainer = Color(0xFF3E2723), + secondary = Color(0xFF8D6E63), + onSecondary = Color(0xFFFFFFFF), + secondaryContainer = Color(0xFFEFEBE9), + onSecondaryContainer = Color(0xFF4E342E), + tertiary = Color(0xFF795548), + onTertiary = Color(0xFFFFFFFF), + tertiaryContainer = Color(0xFFBCAAA4), + onTertiaryContainer = Color(0xFF3E2723), + background = Color(0xFFFFFBF7), + onBackground = Color(0xFF1C1B1A), + surface = Color(0xFFF5F0EB), + onSurface = Color(0xFF1C1B1A), + surfaceVariant = Color(0xFFEFEBE9), + onSurfaceVariant = Color(0xFF5D4037), + outline = Color(0xFFBCAAA4), + outlineVariant = Color(0xFFD7CCC8), + error = Color(0xFFBA1A1A), + onError = Color(0xFFFFFFFF), + errorContainer = Color(0xFFFFDAD6), + onErrorContainer = Color(0xFF410002), + inverseSurface = Color(0xFF322F2D), + inverseOnSurface = Color(0xFFF5F0EB), + inversePrimary = Color(0xFFD7CCC8), + surfaceTint = Color(0xFF6D4C41), +) + +// ═══ Тёмная палитра — «Эспрессо» ═══ +private val DarkColorScheme = darkColorScheme( + primary = Color(0xFFD7CCC8), + onPrimary = Color(0xFF3E2723), + primaryContainer = Color(0xFF5D4037), + onPrimaryContainer = Color(0xFFEFEBE9), + secondary = Color(0xFFBCAAA4), + onSecondary = Color(0xFF3E2723), + secondaryContainer = Color(0xFF4E342E), + onSecondaryContainer = Color(0xFFEFEBE9), + tertiary = Color(0xFFA1887F), + onTertiary = Color(0xFF3E2723), + tertiaryContainer = Color(0xFF5D4037), + onTertiaryContainer = Color(0xFFEFEBE9), + background = Color(0xFF1A1614), + onBackground = Color(0xFFEDE0D4), + surface = Color(0xFF211D1B), + onSurface = Color(0xFFEDE0D4), + surfaceVariant = Color(0xFF2C2624), + onSurfaceVariant = Color(0xFFD7CCC8), + outline = Color(0xFF8D6E63), + outlineVariant = Color(0xFF4E342E), + error = Color(0xFFFFB4AB), + onError = Color(0xFF690005), + errorContainer = Color(0xFF93000A), + onErrorContainer = Color(0xFFFFDAD6), + inverseSurface = Color(0xFFEDE0D4), + inverseOnSurface = Color(0xFF322F2D), + inversePrimary = Color(0xFF6D4C41), + surfaceTint = Color(0xFFD7CCC8), +) + +// ═══ Тёмная палитра — «Цвет 1» ═══ +private val IndigoLightColorScheme = lightColorScheme( + primary = Color(0xFF5B588D), + onPrimary = Color(0xFFFFFFFF), + primaryContainer = Color(0xFFE2DFFF), + onPrimaryContainer = Color(0xFF1A1744), + secondary = Color(0xFF5B588D), + onSecondary = Color(0xFFFFFFFF), + secondaryContainer = Color(0xFFE2DFFF), + onSecondaryContainer = Color(0xFF1A1744), + background = Color(0xFFFBF8FF), + onBackground = Color(0xFF1B1B1F), + surface = Color(0xFFF6F3FA), + onSurface = Color(0xFF1B1B1F), + surfaceVariant = Color(0xFFE4E1EC), + onSurfaceVariant = Color(0xFF47464F), + outline = Color(0xFF787680), + outlineVariant = Color(0xFFC8C5D0), +) + +private val IndigoDarkColorScheme = darkColorScheme( + primary = Color(0xFFC4C0FF), + onPrimary = Color(0xFF2D2A5B), + primaryContainer = Color(0xFF434073), + onPrimaryContainer = Color(0xFFE2DFFF), + secondary = Color(0xFFC4C0FF), + onSecondary = Color(0xFF2D2A5B), + secondaryContainer = Color(0xFF434073), + onSecondaryContainer = Color(0xFFE2DFFF), + background = Color(0xFF131316), + onBackground = Color(0xFFE4E1E6), + surface = Color(0xFF1B1B1F), + onSurface = Color(0xFFC8C5D0), + surfaceVariant = Color(0xFF47464F), + onSurfaceVariant = Color(0xFFC8C5D0), + outline = Color(0xFF918F9A), + outlineVariant = Color(0xFF47464F), +) + +// ═══ Палитра «Цвет 2» ═══ +private val ForestLightColorScheme = lightColorScheme( + primary = Color(0xFF5F5D68), + onPrimary = Color(0xFFFFFFFF), + primaryContainer = Color(0xFFE5E0F0), + onPrimaryContainer = Color(0xFF1C1A23), + secondary = Color(0xFF5F5D68), + onSecondary = Color(0xFFFFFFFF), + secondaryContainer = Color(0xFFE5E0F0), + onSecondaryContainer = Color(0xFF1C1A23), + background = Color(0xFFFCF8FF), + onBackground = Color(0xFF1D1B20), + surface = Color(0xFFF7F2FA), + onSurface = Color(0xFF1D1B20), + surfaceVariant = Color(0xFFE6E0E9), + onSurfaceVariant = Color(0xFF48454E), + outline = Color(0xFF79747E), + outlineVariant = Color(0xFFCAC4D0), +) + +private val ForestDarkColorScheme = darkColorScheme( + primary = Color(0xFFC8C4D3), + onPrimary = Color(0xFF312F38), + primaryContainer = Color(0xFF474550), + onPrimaryContainer = Color(0xFFE5E0F0), + secondary = Color(0xFFC8C4D3), + onSecondary = Color(0xFF312F38), + secondaryContainer = Color(0xFF474550), + onSecondaryContainer = Color(0xFFE5E0F0), + background = Color(0xFF141318), + onBackground = Color(0xFFE6E1E5), + surface = Color(0xFF1D1B20), + onSurface = Color(0xFFCAC4D0), + surfaceVariant = Color(0xFF48454E), + onSurfaceVariant = Color(0xFFCAC4D0), + outline = Color(0xFF938F99), + outlineVariant = Color(0xFF48454E), +) + +private fun getAppColorScheme(palette: String, isDark: Boolean): androidx.compose.material3.ColorScheme { + return when(palette) { + "espresso" -> if (isDark) DarkColorScheme else LightColorScheme + "forest" -> if (isDark) ForestDarkColorScheme else ForestLightColorScheme + else -> if (isDark) IndigoDarkColorScheme else IndigoLightColorScheme + } +} + +// ═══ Расширенные цвета для кастомных элементов ═══ +object AppColors { + val connected = Color(0xFF4CAF50) + val connectedContainer = Color(0xFF4CAF50).copy(alpha = 0.12f) + val onConnected = Color(0xFF1B5E20) + + val connectedDark = Color(0xFF81C784) + val connectedContainerDark = Color(0xFF81C784).copy(alpha = 0.15f) + val onConnectedDark = Color(0xFFC8E6C9) + + val warning = Color(0xFFFFA726) + val warningDark = Color(0xFFFFCC80) + + val terminalBg = Color(0xFF1A1A2E) + val terminalBgDark = Color(0xFF0D0D1A) + val terminalText = Color(0xFFE0E0E0) + val terminalGreen = Color(0xFF4CAF50) + val terminalBlue = Color(0xFF42A5F5) + val terminalRed = Color(0xFFEF5350) + val terminalOrange = Color(0xFFFF7043) + val terminalYellow = Color(0xFFFFC107) + val terminalCounter = Color(0xFF1E88E5) + + val github = Color(0xFF24292E) + val githubDark = Color(0xFF333C47) + + val donate = Color(0xFF8B3FFD) +} + +@Composable +fun TgWsProxyTheme( + themeMode: String = "system", + dynamicColor: Boolean = true, + themePalette: String = "indigo", + content: @Composable () -> Unit +) { + val darkTheme = when (themeMode) { + "dark" -> true + "light" -> false + else -> isSystemInDarkTheme() + } + + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + else -> getAppColorScheme(themePalette, darkTheme) + } + val view = LocalView.current + + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + val navigationBarColor = if (darkTheme) { + Color.Transparent + } else { + lerp(colorScheme.background, colorScheme.surface, 0.55f) + } + window.statusBarColor = Color.Transparent.toArgb() + window.navigationBarColor = navigationBarColor.toArgb() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.isNavigationBarContrastEnforced = false + window.isStatusBarContrastEnforced = false + } + WindowCompat.getInsetsController(window, view).apply { + isAppearanceLightStatusBars = !darkTheme + isAppearanceLightNavigationBars = !darkTheme + } + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = TgWsProxyTypography, + content = content + ) +} 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 00000000..cfe301da Binary files /dev/null and b/app/src/main/res/drawable/app_bg.webp differ diff --git a/app/src/main/res/drawable/ic_auto.xml b/app/src/main/res/drawable/ic_auto.xml new file mode 100644 index 00000000..2af76ea5 --- /dev/null +++ b/app/src/main/res/drawable/ic_auto.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_da.xml b/app/src/main/res/drawable/ic_da.xml new file mode 100644 index 00000000..b5f0bf62 --- /dev/null +++ b/app/src/main/res/drawable/ic_da.xml @@ -0,0 +1,22 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_da_black.xml b/app/src/main/res/drawable/ic_da_black.xml new file mode 100644 index 00000000..e276f002 --- /dev/null +++ b/app/src/main/res/drawable/ic_da_black.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_dark_mode.xml b/app/src/main/res/drawable/ic_dark_mode.xml new file mode 100644 index 00000000..5ada9d71 --- /dev/null +++ b/app/src/main/res/drawable/ic_dark_mode.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_github.xml b/app/src/main/res/drawable/ic_github.xml new file mode 100644 index 00000000..63d59618 --- /dev/null +++ b/app/src/main/res/drawable/ic_github.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_heart.xml b/app/src/main/res/drawable/ic_heart.xml new file mode 100644 index 00000000..eb62b885 --- /dev/null +++ b/app/src/main/res/drawable/ic_heart.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_light_mode.xml b/app/src/main/res/drawable/ic_light_mode.xml new file mode 100644 index 00000000..0c9f22a2 --- /dev/null +++ b/app/src/main/res/drawable/ic_light_mode.xml @@ -0,0 +1,9 @@ + + + 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 00000000..034a443e --- /dev/null +++ b/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_palette.xml b/app/src/main/res/drawable/ic_palette.xml new file mode 100644 index 00000000..210ea90a --- /dev/null +++ b/app/src/main/res/drawable/ic_palette.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 00000000..6ccdf88b --- /dev/null +++ b/app/src/main/res/drawable/ic_settings.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 00000000..1024271e --- /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 00000000..172430c7 --- /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 00000000..64ab7e98 --- /dev/null +++ b/app/src/main/res/drawable/ic_transparent_fg.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/font/inter_bold.ttf b/app/src/main/res/font/inter_bold.ttf new file mode 100644 index 00000000..7e1deec3 Binary files /dev/null and b/app/src/main/res/font/inter_bold.ttf differ diff --git a/app/src/main/res/font/inter_medium.ttf b/app/src/main/res/font/inter_medium.ttf new file mode 100644 index 00000000..7e573f64 Binary files /dev/null and b/app/src/main/res/font/inter_medium.ttf differ diff --git a/app/src/main/res/font/inter_regular.ttf b/app/src/main/res/font/inter_regular.ttf new file mode 100644 index 00000000..012d1b47 Binary files /dev/null and b/app/src/main/res/font/inter_regular.ttf differ diff --git a/app/src/main/res/font/inter_semibold.ttf b/app/src/main/res/font/inter_semibold.ttf new file mode 100644 index 00000000..4be54399 Binary files /dev/null and b/app/src/main/res/font/inter_semibold.ttf differ 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 00000000..32e6441c --- /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 00000000..32e6441c --- /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 00000000..882e4d63 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 00000000..ba0ec420 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 00000000..ecf7133b 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 00000000..849bfb43 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 00000000..8a19f533 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 00000000..e023165c 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 00000000..c969fdf3 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 00000000..6ebec7e0 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 00000000..7d4079fd 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 00000000..537741a2 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 00000000..3e80193f 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 00000000..4cf6f685 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 00000000..1ebf6ffd 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 00000000..a3aa47f6 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 00000000..bb266d5b 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 00000000..79f01f80 Binary files /dev/null and b/app/src/main/res/play_store_512.png differ diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml new file mode 100644 index 00000000..f8c96fc3 --- /dev/null +++ b/app/src/main/res/values-ru/strings.xml @@ -0,0 +1,129 @@ + + Telegram WS Proxy + Подключение + Подключено + Отключено + Запуск + Применить в Telegram + Скопировано + Скопировать + Прямое + Пул x%1$d + Порт %1$d + Тема + Системная + Светлая + Тёмная + Динамические + Палитра + Настройки + Подключение + Порт + Авто (Включён CF) + Настроить адреса DC + Пул WS + Секретный ключ + Автозапуск + Адреса датацентров + Медиа датацентры + Экспериментальный режим + Готово + Telegram не найден! + Лог событий + Очистить + NULL - логи отключены + Информация + Проверяем GitHub releases... + На GitHub доступна версия %1$s + Последняя версия: %1$s + Последняя проверка завершилась ошибкой + Проверить GitHub вручную + Действия + %1$d пункта + %1$d ссылки + Поднять вопрос + Открыть GitHub issue + Собрать отчёт + Android, ABI, настройки, ошибки + Отчёт сформирован и скопирован + Справка + Коротко про Cloudflare, пул WS, ручные DC и долгий запуск + Проверить обновления + Не удалось проверить + Не удалось проверить. Последняя известная версия: %1$s + Не удалось проверить обновления + У вас уже последняя версия: %1$s + О проекте + Автор Android-версии + GitHub профиль amurcanov + Репозиторий Android-форка + Исходники и релизы этого приложения + Оригинальный tg-ws-proxy + Исходная идея и upstream от Flowseal + Полезный материал + Заметки по работе прокси от IMDelewer + Обновление отложено на 24 часа. + Авто / Адреса датацентров + При включенном CloudFlare ручные адреса DC обычно не нужны: прокси использует CF-маршрут. Если CloudFlare выключен, соединение идёт напрямую на Telegram DC, и тогда можно задать адреса вручную. В обычном режиме чаще всего достаточно DC2 и DC4; остальные адреса нужны в основном для ручной настройки и диагностики. + Этот режим направляет соединение через WebSocket-домены за Cloudflare. На части мобильных сетей он работает стабильнее, но итог зависит от маршрута, DNS и конкретного провайдера. Если на вашей сети подключение стало дольше или менее стабильным, имеет смысл сравнить работу с выключенным CF. + Количество заранее подготовленных WebSocket-соединений. Больший пул может уменьшить задержку при первом подключении и загрузке медиа, но увеличивает число фоновых соединений. Для большинства сценариев достаточно 2-4; повышать значение стоит только если реально видна польза на вашей сети. + Специальный 16-байтовый ключ шифрования MTProto. Меняйте его только в случае, если старой ссылкой для подключения завладели посторонние. + Открывает ручную настройку всех обычных и media-датацентров: DC1, DC3, DC5, DC203 и их media-вариантов. Он нужен для диагностики, тестов и нестандартных маршрутов. Если у вас нет явной задачи под ручную маршрутизацию, лучше держать этот режим выключенным. + Включает попытку поднять прокси автоматически после перезагрузки устройства. Это удобно, если вы используете локальный прокси постоянно. На некоторых прошивках запуск может произойти не мгновенно: система иногда завершает его только после полной загрузки Android или первого разблокирования. + Если долго подключается + Если после запуска прокси Telegram долго висит на подключении, это обычно означает неудачный текущий маршрут, а не падение приложения. В такой ситуации полезно быстро перезапустить прокси и сравнить поведение с включенным и выключенным CloudFlare. + Понятно + Локальный MTProto-прокси для Android с прямым маршрутом и Cloudflare-режимом. Удобен для сетей, где Telegram грузится нестабильно или упирается в маршрут. + Поддержать развитие + Поддержка проекта + Если приложение тебе реально помогает, проект можно поддержать. + Внутри есть варианты доната для автора Android-версии и для автора оригинальной идеи. + Открыть варианты доната + Прямой + CF-домен: %1$s + нет + Версия приложения: %1$s + Андроид: %1$s (SDK %2$d) + Устройство: %1$s / %2$s / %3$s + Код устройства: %1$s + Продукт: %1$s + Все ABI: %1$s + Настройки: + Режим: %1$s + WS-пул: %1$d + Порт: %1$s + Последние ошибки: + Донат разработчикам + Закрыть + Задонатить автору данного андроид приложения вы можете тут + Задонатить автору оригинальной идеи вы можете тут + Найден новый tag + Доступно обновление + На GitHub обнаружен более новый tag %1$s. Похоже, опубликованный release ещё не догнал его. + Вышла новая версия приложения %1$s. Можно открыть страницу релиза и обновиться вручную. + Обновить + Позже + Не удалось запросить работу в фоне + Прокси + Логи + Неверный порт + Запуск прокси... + Ошибка запуска (код: %1$d) + Ошибка: %1$s + Прокси работает + ⚠ Прокси не отвечает + Трафик: %1$s · %2$d сесс. + Перезапуск прокси... + Остановка прокси... + Фоновый Прокси + Уведомление о работе прокси-сервера + Перезапуск + Отключить + Подключен + Отключен + 7 ч + 24 ч + 48 ч + Никогда + %1$d ч + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..927922ea --- /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/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..47e37acb --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,129 @@ + + Telegram WS Proxy + Connecting + Connected + Disconnected + Launch + Apply in Telegram + Copied + Copy + Direct + Pool x%1$d + Port %1$d + Theme + System + Light + Dark + Dynamic + Palette + Settings + Connection + Port + Auto (CF enabled) + Configure DC addresses + WS pool + Secret key + Autostart + Data center addresses + Media data centers + Experimental mode + Done + Telegram not found! + Event log + Clear + NULL - logs disabled + Info + Checking GitHub releases... + Version %1$s is available on GitHub + Latest version: %1$s + Last check failed + Check GitHub manually + Actions + %1$d items + %1$d links + Raise an issue + Open a GitHub issue + Build report + Android, ABI, settings, errors + Report generated and copied + Help + Brief notes about Cloudflare, WS pool, manual DCs, and slow startup + Check for updates + Could not check + Could not check. Last known version: %1$s + Could not check for updates + You already have the latest version: %1$s + About the project + Android version author + GitHub profile amurcanov + Android fork repository + Source code and releases for this app + Original tg-ws-proxy + Original idea and upstream by Flowseal + Useful material + Proxy notes by IMDelewer + Update postponed for 24 hours. + Auto / data center addresses + When Cloudflare is enabled, manual DC addresses are usually not needed: the proxy uses the CF route. If Cloudflare is disabled, the connection goes directly to Telegram DCs, and you can set addresses manually. In normal mode DC2 and DC4 are usually enough; the other addresses are mainly for manual setup and diagnostics. + This mode routes the connection through WebSocket domains behind Cloudflare. It can be more stable on some mobile networks, but the result depends on routing, DNS, and the provider. If the connection became slower or less stable on your network, compare it with CF disabled. + The number of pre-warmed WebSocket connections. A larger pool can reduce delay during the first connection and media loading, but increases background connections. For most cases 2-4 is enough; increase it only if you can see a real benefit on your network. + A special 16-byte MTProto encryption key. Change it only if someone else got access to your old connection link. + Opens manual setup for all regular and media data centers: DC1, DC3, DC5, DC203, and their media variants. It is useful for diagnostics, tests, and non-standard routes. If you do not explicitly need manual routing, keep this mode off. + Attempts to start the proxy automatically after device reboot. This is convenient if you use the local proxy all the time. On some firmware it may not start immediately: the system may finish it only after Android fully boots or after the first unlock. + If connecting takes a long time + If Telegram keeps connecting for a long time after the proxy starts, it usually means the current route failed, not that the app crashed. In this case, quickly restart the proxy and compare behavior with Cloudflare enabled and disabled. + Got it + Local MTProto proxy for Android with direct routing and Cloudflare mode. Useful on networks where Telegram loads unstably or gets stuck on routing. + Support development + Project support + If the app really helps you, you can support the project. + Inside are donation options for the Android version author and for the original idea author. + Open donation options + Direct + CF domain: %1$s + none + App version: %1$s + Android: %1$s (SDK %2$d) + Device: %1$s / %2$s / %3$s + Device code: %1$s + Product: %1$s + All ABI: %1$s + Settings: + Mode: %1$s + WS pool: %1$d + Port: %1$s + Recent errors: + Donate to developers + Close + You can donate to the author of this Android app here + You can donate to the author of the original idea here + New tag found + Update available + GitHub has a newer tag %1$s. It looks like the published release has not caught up yet. + A new app version %1$s is out. You can open the release page and update manually. + Update + Later + Could not request background work + Proxy + Logs + Invalid port + Starting proxy... + Start error (code: %1$d) + Error: %1$s + Proxy is running + ⚠ Proxy is not responding + Traffic: %1$s · %2$d sess. + Restarting proxy... + Stopping proxy... + Background Proxy + Proxy server status notification + Restart + Disconnect + Connected + Disconnected + 7 h + 24 h + 48 h + Never + %1$d h + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 00000000..69e61e7d --- /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 00000000..fb279456 --- /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 "9.0.1" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.1.20" apply false +} diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..cf7acde0 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module tg-ws-proxy + +go 1.26 diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 00000000..44d30976 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonTransitiveRClass=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..2c352119 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..2e111328 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 00000000..f5feea6d --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..9d21a218 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/icon.ico b/icon.ico deleted file mode 100644 index 86c4b191..00000000 Binary files a/icon.ico and /dev/null differ diff --git a/linux.py b/linux.py deleted file mode 100644 index 664c9484..00000000 --- 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 46eb5cfe..00000000 --- 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 ab27315f..00000000 --- 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 5f389459..00000000 --- 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 1c8dd813..00000000 --- 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 9e2406ef..00000000 --- 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 b6e55399..00000000 --- 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 05240360..00000000 --- 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 00000000..118ea033 --- /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 00000000..37c90e58 --- /dev/null +++ b/tg-ws-proxy.go @@ -0,0 +1,3039 @@ +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/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "math" + "net" + "net/http" + "os" + "os/signal" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + "unsafe" +) + +// --------------------------------------------------------------------------- +// Constants & Configuration +// --------------------------------------------------------------------------- + +const ( + defaultPort = 1443 + tcpNodelay = true + defaultRecvBuf = 256 * 1024 + defaultSendBuf = 256 * 1024 + defaultPoolSz = 4 + + wsPoolMaxAge = 120.0 + + dcFailCooldown = 10.0 + + wsFailTimeout = 2.0 + + poolMaintainInterval = 5 + + // Bridge read deadlines — short enough to detect dead connections on mobile + bridgeReadTimeout = 2 * time.Minute + bridgePingInterval = 30 * time.Second + wsWriteTimeout = 5 * time.Second + wsControlTimeout = 2 * time.Second + wsPoolProbeTimeout = 1200 * time.Millisecond + wsPoolProbeAfter = 8.0 + wsBridgeChunkSize = 64 * 1024 + pooledFrameCap = wsBridgeChunkSize + 32 + + wsPoolReuseMaxAge = 30.0 + + cfproxyCacheFileName = "cfproxy-domains-cache.txt" + cfproxyActiveFileName = "cfproxy-active-domain.txt" + cfproxyRefreshInterval = 12 * time.Hour + cfproxyDialPhaseTimeout = 4 * time.Second + cfproxyFallbackParallel = 2 + cfproxy429Cooldown = 45 * time.Second + cfproxy429MaxCooldown = 5 * time.Minute + cfproxyGlobalParallel = 4 +) + +var ( + recvBuf = defaultRecvBuf + sendBuf = defaultSendBuf + poolSize atomic.Int32 + logVerbose = false +) + +type cfproxy429State struct { + until time.Time + strikes int +} + +func init() { + poolSize.Store(defaultPoolSz) +} + +// Cloudflare proxy config +var ( + cfproxyEnabled = true + cfproxyUserDomain = "" + cfproxyDomains []string + activeCfDomain string + cfproxyCacheDir = "" + cfproxyMu sync.RWMutex + cfproxy429StateByDomain = make(map[string]cfproxy429State) + cfproxy429Mu sync.RWMutex + cfproxyAttemptSem = make(chan struct{}, cfproxyGlobalParallel) +) + +const cfproxyDomainsURL = "https://raw.githubusercontent.com/Flowseal/tg-ws-proxy/main/.github/cfproxy-domains.txt" + +// MTProto proxy secret (hex, 32 chars = 16 bytes) +var ( + proxySecret = "00000000000000000000000000000000" + proxySecretMu sync.RWMutex +) + +// FakeTLS config (ee-secret) +var ( + fakeTlsEnabled = false + fakeTlsDomain = "" + fakeTlsMu sync.RWMutex +) + +// DNS over HTTPS (DoH) Cache and Clients +type dohCacheEntry struct { + ip string + exp time.Time +} + +var ( + dohCache sync.Map + dohClient = &http.Client{ + Timeout: 1500 * time.Millisecond, + Transport: &http.Transport{ + MaxIdleConns: 10, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 1 * time.Second, + }, + } + githubClient = &http.Client{ + Timeout: 10 * time.Second, + } +) + +func connectOneWS(ctx context.Context, ip string, domains []string) *RawWebSocket { + for _, d := range domains { + ws, err := wsConnect(ctx, ip, d, "/apiws", 5.0) + if err == nil { + return ws + } + } + return nil +} + +var dcDefaultIPs = map[int]string{ + 1: "149.154.175.50", + 2: "149.154.167.51", + 3: "149.154.175.100", + 4: "149.154.167.91", + 5: "149.154.171.5", + 203: "91.105.192.100", +} + +func resolveConfiguredTarget(dc int, isMedia bool) (string, bool) { + dcOptMu.RLock() + defer dcOptMu.RUnlock() + + if isMedia { + if target, ok := dcOpt[-dc]; ok && target != "" { + return target, true + } + } + if target, ok := dcOpt[dc]; ok && target != "" { + return target, true + } + return "", false +} + +func resolveFallbackTarget(dc int, isMedia bool) string { + return dcDefaultIPs[dc] +} + +// --------------------------------------------------------------------------- +// 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) { + logVerbose = verbose + flags := 0 + out := androidLogWriter{} + logInfo = log.New(out, "", 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) + } + signal.Ignore(syscall.SIGPIPE) +} + +// --------------------------------------------------------------------------- +// Cloudflare proxy domain decoding +// --------------------------------------------------------------------------- + +var cfproxyEnc = []string{"virkgj.com", "vmmzovy.com", "mkuosckvso.com", "zaewayzmplad.com", "twdmbzcm.com"} + +func decodeCfDomain(s string) string { + if !strings.HasSuffix(s, ".com") { + return s + } + suffix := string([]byte{46, 99, 111, 46, 117, 107}) + p := s[:len(s)-4] + n := 0 + for _, c := range p { + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') { + n++ + } + } + var result []byte + for _, c := range []byte(p) { + if c >= 'a' && c <= 'z' { + result = append(result, byte((int(c-'a')-n%26+26)%26+'a')) + } else if c >= 'A' && c <= 'Z' { + result = append(result, byte((int(c-'A')-n%26+26)%26+'A')) + } else { + result = append(result, c) + } + } + return string(result) + suffix +} + +func normalizeCfDomain(s string) string { + decoded := strings.ToLower(strings.TrimSpace(decodeCfDomain(s))) + decoded = strings.TrimSuffix(decoded, ".") + if decoded == "" || !strings.HasSuffix(decoded, ".co.uk") { + return "" + } + return decoded +} + +func defaultCfproxyDomains() []string { + domains := make([]string, 0, len(cfproxyEnc)) + for _, enc := range cfproxyEnc { + if domain := normalizeCfDomain(enc); domain != "" { + domains = append(domains, domain) + } + } + return domains +} + +func mergeCfproxyDomains(lists ...[]string) []string { + seen := make(map[string]struct{}) + merged := make([]string, 0) + for _, list := range lists { + for _, raw := range list { + domain := normalizeCfDomain(raw) + if domain == "" { + continue + } + if _, ok := seen[domain]; ok { + continue + } + seen[domain] = struct{}{} + merged = append(merged, domain) + } + } + return merged +} + +func clearCfproxy429Cooldowns() { + cfproxy429Mu.Lock() + cfproxy429StateByDomain = make(map[string]cfproxy429State) + cfproxy429Mu.Unlock() +} + +func clearCfproxy429Cooldown(domain string) { + domain = normalizeCfDomain(domain) + if domain == "" { + return + } + + cfproxy429Mu.Lock() + delete(cfproxy429StateByDomain, domain) + cfproxy429Mu.Unlock() +} + +func retryAfterDelay(err error) time.Duration { + var wsErr *WsHandshakeError + if !errors.As(err, &wsErr) || wsErr == nil { + return 0 + } + + retryAfter := strings.TrimSpace(wsErr.Headers["retry-after"]) + if retryAfter == "" { + return 0 + } + + if seconds, convErr := strconv.Atoi(retryAfter); convErr == nil && seconds > 0 { + return time.Duration(seconds) * time.Second + } + + if when, convErr := http.ParseTime(retryAfter); convErr == nil { + if delay := time.Until(when); delay > 0 { + return delay + } + } + + return 0 +} + +func nextCfproxy429CooldownDelay(prev cfproxy429State, retryAfter time.Duration) time.Duration { + if retryAfter > 0 { + if retryAfter > cfproxy429MaxCooldown { + return cfproxy429MaxCooldown + } + return retryAfter + } + + strikes := prev.strikes + if prev.until.IsZero() || time.Since(prev.until) > cfproxy429MaxCooldown { + strikes = 0 + } + + delay := cfproxy429Cooldown + for i := 0; i < strikes; i++ { + delay *= 2 + if delay >= cfproxy429MaxCooldown { + return cfproxy429MaxCooldown + } + } + + if delay > cfproxy429MaxCooldown { + return cfproxy429MaxCooldown + } + return delay +} + +func markCfproxy429Cooldown(domain string, err error) { + domain = normalizeCfDomain(domain) + if domain == "" { + return + } + + retryAfter := retryAfterDelay(err) + cfproxy429Mu.Lock() + prev := cfproxy429StateByDomain[domain] + delay := nextCfproxy429CooldownDelay(prev, retryAfter) + strikes := prev.strikes + 1 + if prev.until.IsZero() || time.Since(prev.until) > cfproxy429MaxCooldown { + strikes = 1 + } + cfproxy429StateByDomain[domain] = cfproxy429State{ + until: time.Now().Add(delay), + strikes: strikes, + } + cfproxy429Mu.Unlock() + + logDebug.Printf(" CF cooldown %s: %.0fs after 429", domain, math.Ceil(delay.Seconds())) +} + +func cfproxy429CooldownRemaining(domain string) time.Duration { + domain = normalizeCfDomain(domain) + if domain == "" { + return 0 + } + + cfproxy429Mu.RLock() + state, ok := cfproxy429StateByDomain[domain] + cfproxy429Mu.RUnlock() + if !ok { + return 0 + } + + remaining := time.Until(state.until) + if remaining <= 0 { + cfproxy429Mu.Lock() + delete(cfproxy429StateByDomain, domain) + cfproxy429Mu.Unlock() + return 0 + } + return remaining +} + +func acquireCfproxyAttemptSlot(ctx context.Context) bool { + select { + case cfproxyAttemptSem <- struct{}{}: + return true + case <-ctx.Done(): + return false + } +} + +func releaseCfproxyAttemptSlot() { + select { + case <-cfproxyAttemptSem: + default: + } +} + +func cfproxyCachePath() string { + cfproxyMu.RLock() + cacheDir := strings.TrimSpace(cfproxyCacheDir) + cfproxyMu.RUnlock() + if cacheDir == "" { + return "" + } + return filepath.Join(cacheDir, cfproxyCacheFileName) +} + +func cfproxyActiveDomainPath() string { + cfproxyMu.RLock() + cacheDir := strings.TrimSpace(cfproxyCacheDir) + cfproxyMu.RUnlock() + if cacheDir == "" { + return "" + } + return filepath.Join(cacheDir, cfproxyActiveFileName) +} + +func loadCfproxyDomainsFromCache() []string { + cachePath := cfproxyCachePath() + if cachePath == "" { + return nil + } + + data, err := os.ReadFile(cachePath) + if err != nil { + return nil + } + + return mergeCfproxyDomains(strings.Split(string(data), "\n")) +} + +func loadActiveCfproxyDomain() string { + activePath := cfproxyActiveDomainPath() + if activePath == "" { + return "" + } + + data, err := os.ReadFile(activePath) + if err != nil { + return "" + } + return normalizeCfDomain(string(data)) +} + +func saveCfproxyDomainsToCache(domains []string) { + cachePath := cfproxyCachePath() + if cachePath == "" || len(domains) == 0 { + return + } + + if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil { + logDebug.Printf(" CF: кеш создать не удалось: %s", err) + return + } + + data := strings.Join(domains, "\n") + if err := os.WriteFile(cachePath, []byte(data), 0o644); err != nil { + logDebug.Printf(" CF: кеш сохранить не удалось: %s", err) + } +} + +func saveActiveCfproxyDomain(domain string) { + activePath := cfproxyActiveDomainPath() + domain = normalizeCfDomain(domain) + if activePath == "" || domain == "" { + return + } + + if err := os.MkdirAll(filepath.Dir(activePath), 0o755); err != nil { + logDebug.Printf(" CF: active-domain кеш создать не удалось: %s", err) + return + } + + if err := os.WriteFile(activePath, []byte(domain), 0o644); err != nil { + logDebug.Printf(" CF: active-domain кеш сохранить не удалось: %s", err) + } +} + +func shouldRefreshCfproxyDomains() bool { + cachePath := cfproxyCachePath() + if cachePath == "" { + return true + } + + info, err := os.Stat(cachePath) + if err != nil { + return true + } + + return time.Since(info.ModTime()) >= cfproxyRefreshInterval +} + +func setActiveCfproxyDomainLocked(preferred string) { + if len(cfproxyDomains) == 0 { + activeCfDomain = "" + return + } + preferred = normalizeCfDomain(preferred) + for _, domain := range cfproxyDomains { + if domain == preferred { + activeCfDomain = domain + return + } + } + activeCfDomain = cfproxyDomains[0] +} + +func initCfproxyDomains() { + defaults := defaultCfproxyDomains() + cached := loadCfproxyDomainsFromCache() + persistedActive := loadActiveCfproxyDomain() + + cfproxyMu.Lock() + defer cfproxyMu.Unlock() + if cfproxyUserDomain != "" { + cfproxyDomains = []string{cfproxyUserDomain} + activeCfDomain = cfproxyUserDomain + return + } + + if len(cached) > 0 { + cfproxyDomains = mergeCfproxyDomains(cached, defaults) + logInfo.Printf(" CF: кеш доменов загружен (%d шт.)", len(cached)) + } else { + cfproxyDomains = defaults + } + setActiveCfproxyDomainLocked(persistedActive) +} + +func startCfproxyRefresh(ctx context.Context) { + if !shouldRefreshCfproxyDomains() { + logDebug.Printf(" CF: кеш свежий, пропускаю обновление списка") + return + } + + go func() { + for i := 0; i < 3; i++ { + if tryRefreshCfproxyDomains(ctx) { + return + } + select { + case <-ctx.Done(): + return + case <-time.After(10 * time.Second): + continue + } + } + logDebug.Printf(" CF: обновить список доменов не удалось, остаюсь на кеше/встроенном списке") + }() +} + +func tryRefreshCfproxyDomains(ctx context.Context) bool { + cfproxyMu.RLock() + hasUserDomain := cfproxyUserDomain != "" + cfproxyMu.RUnlock() + if hasUserDomain { + return true + } + + req, err := http.NewRequestWithContext(ctx, "GET", cfproxyDomainsURL, nil) + if err != nil { + return false + } + req.Header.Set("User-Agent", "Mozilla/5.0 tg-ws-proxy-android") + + resp, err := githubClient.Do(req) + if err != nil { + logDebug.Printf(" CF: GitHub недоступен: %s", err) + return false + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + logDebug.Printf(" CF: GitHub вернул %d", resp.StatusCode) + return false + } + + var newDomains []string + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if domain := normalizeCfDomain(line); domain != "" { + newDomains = append(newDomains, domain) + } + } + if err := scanner.Err(); err != nil { + logDebug.Printf(" CF: список доменов прочитать не удалось: %s", err) + return false + } + + if len(newDomains) > 0 { + merged := mergeCfproxyDomains(newDomains, defaultCfproxyDomains()) + cfproxyMu.Lock() + if cfproxyUserDomain != "" { + cfproxyMu.Unlock() + return true + } + currentActive := activeCfDomain + cfproxyDomains = merged + setActiveCfproxyDomainLocked(currentActive) + cfproxyMu.Unlock() + + saveCfproxyDomainsToCache(merged) + logInfo.Printf(" CF: список доменов обновлен (%d шт.)", len(newDomains)) + return true + } + return false +} + +// --------------------------------------------------------------------------- +// Telegram IP ranges & DC mapping +// --------------------------------------------------------------------------- + +var validProtos = map[uint32]bool{ + 0xEFEFEFEF: true, + 0xEEEEEEEE: true, + 0xDDDDDDDD: true, +} + +var dcOverrides = map[int]int{ + 203: 2, +} + +// --------------------------------------------------------------------------- +// 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 + connectionsActive atomic.Int64 + connectionsWs atomic.Int64 + connectionsTcpFallback atomic.Int64 + connectionsCfproxy atomic.Int64 + connectionsHttpReject atomic.Int64 + connectionsPassthrough atomic.Int64 + connectionsBad 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 active=%d ws=%d tcp_fb=%d cf=%d bad=%d err=%d pool=%d/%d up=%s down=%s", + s.connectionsTotal.Load(), s.connectionsActive.Load(), s.connectionsWs.Load(), + s.connectionsTcpFallback.Load(), s.connectionsCfproxy.Load(), s.connectionsBad.Load(), + s.wsErrors.Load(), ph, ph+pm, humanBytes(s.bytesUp.Load()), humanBytes(s.bytesDown.Load()), + ) +} + +func (s *Stats) SummaryRu() string { + parts := []string{fmt.Sprintf("акт:%d", s.connectionsActive.Load())} + if ws := s.connectionsWs.Load(); ws > 0 { + parts = append(parts, fmt.Sprintf("ws:%d", ws)) + } + if cf := s.connectionsCfproxy.Load(); cf > 0 { + parts = append(parts, fmt.Sprintf("cf:%d", cf)) + } + if tcp := s.connectionsTcpFallback.Load(); tcp > 0 { + parts = append(parts, fmt.Sprintf("tcp:%d", tcp)) + } + if errCount := s.wsErrors.Load(); errCount > 0 { + parts = append(parts, fmt.Sprintf("ош:%d", errCount)) + } + parts = append(parts, fmt.Sprintf("↑%s ↓%s", humanBytes(s.bytesUp.Load()), humanBytes(s.bytesDown.Load()))) + return strings.Join(parts, " | ") +} + +func (s *Stats) Reset() { + s.connectionsTotal.Store(0) + s.connectionsActive.Store(0) + s.connectionsWs.Store(0) + s.connectionsTcpFallback.Store(0) + s.connectionsCfproxy.Store(0) + s.connectionsHttpReject.Store(0) + s.connectionsPassthrough.Store(0) + s.connectionsBad.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 { + 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) { + if tc, ok := conn.(*net.TCPConn); ok { + if tcpNodelay { + _ = tc.SetNoDelay(true) + } + _ = tc.SetKeepAlive(true) + _ = tc.SetKeepAlivePeriod(30 * time.Second) + _ = tc.SetReadBuffer(recvBuf) + _ = tc.SetWriteBuffer(sendBuf) + } +} + +// --------------------------------------------------------------------------- +// XOR mask +// --------------------------------------------------------------------------- + +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] + } +} + +// --------------------------------------------------------------------------- +// RawWebSocket +// --------------------------------------------------------------------------- + +var bytesPool = sync.Pool{ + New: func() any { return make([]byte, 131072) }, +} + +func SafeClose(conn net.Conn) { + if conn == nil { + return + } + if tc, ok := conn.(*net.TCPConn); ok { + _ = tc.SetLinger(0) + } + _ = conn.Close() +} + +var tlsConfigPool = &tls.Config{ + ClientSessionCache: tls.NewLRUClientSessionCache(100), +} + +const ( + opText = 0x1 + opBinary = 0x2 + opClose = 0x8 + opPing = 0x9 + opPong = 0xA +) + +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 +} + +type RawWebSocket struct { + conn net.Conn + bufReader *bufio.Reader + writeMu sync.Mutex + closed atomic.Bool +} + +type dohResponse struct { + Answer []struct { + Data string `json:"data"` + Type int `json:"type"` + } `json:"Answer"` +} + +func pickPreferredIP(candidates []string) string { + var fallbackV6 string + for _, candidate := range candidates { + ip := net.ParseIP(strings.TrimSpace(candidate)) + if ip == nil { + continue + } + if ip4 := ip.To4(); ip4 != nil { + return ip4.String() + } + if fallbackV6 == "" { + fallbackV6 = ip.String() + } + } + return fallbackV6 +} + +func resolveDoH(ctx context.Context, domain string) string { + if val, ok := dohCache.Load(domain); ok { + entry := val.(dohCacheEntry) + if time.Now().Before(entry.exp) { + return entry.ip + } + } + + resCh := make(chan string, 10) + dnsCtx, cancel := context.WithTimeout(ctx, 1500*time.Millisecond) + defer cancel() + + udpServers := []string{"1.1.1.1:53", "8.8.8.8:53", "77.88.8.8:53"} + for _, srv := range udpServers { + go func(s string) { + r := &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + d := net.Dialer{Timeout: 800 * time.Millisecond} + return d.DialContext(ctx, "udp", s) + }, + } + ips, err := r.LookupHost(dnsCtx, domain) + if preferred := pickPreferredIP(ips); err == nil && preferred != "" { + select { + case resCh <- preferred: + default: + } + } + }(srv) + } + + endpoints := []string{ + "https://cloudflare-dns.com/dns-query", + "https://dns.google/dns-query", + "https://dns.quad9.net/dns-query", + "https://dns.adguard-dns.com/dns-query", + } + + for _, url := range endpoints { + go func(u string) { + fullURL := fmt.Sprintf("%s?name=%s&type=A", u, domain) + req, err := http.NewRequestWithContext(dnsCtx, "GET", fullURL, nil) + if err != nil { + return + } + req.Header.Set("Accept", "application/dns-json") + resp, err := dohClient.Do(req) + if err != nil { + return + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return + } + var r dohResponse + if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { + return + } + for _, ans := range r.Answer { + if ans.Type == 1 { + select { + case resCh <- ans.Data: + default: + } + return + } + } + }(url) + } + + select { + case ip := <-resCh: + dohCache.Store(domain, dohCacheEntry{ip: ip, exp: time.Now().Add(5 * time.Minute)}) + return ip + case <-dnsCtx.Done(): + return "" + } +} + +func wsConnectTimeout(timeout float64) time.Duration { + if timeout <= 0 { + return 5 * time.Second + } + return time.Duration(timeout * float64(time.Second)) +} + +func wsHandshakeTimeout(total time.Duration) time.Duration { + if total <= 0 { + return 3 * time.Second + } + if total > 3*time.Second { + return 3 * time.Second + } + return total +} + +func contextRemainingTimeout(ctx context.Context, fallback time.Duration) time.Duration { + if deadline, ok := ctx.Deadline(); ok { + remaining := time.Until(deadline) + if remaining > 0 { + return remaining + } + return time.Millisecond + } + return fallback +} + +func newTimedAttemptContext(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc, time.Duration) { + effective := timeout + if effective <= 0 { + effective = 5 * time.Second + } + if deadline, ok := parent.Deadline(); ok { + if remaining := time.Until(deadline); remaining > 0 && remaining < effective { + effective = remaining + } + } + ctx, cancel := context.WithTimeout(parent, effective) + return ctx, cancel, effective +} + +func compactConnError(err error) string { + if err == nil { + return "" + } + if errors.Is(err, context.Canceled) { + return "canceled" + } + if errors.Is(err, context.DeadlineExceeded) { + return "timeout" + } + var wsErr *WsHandshakeError + if errors.As(err, &wsErr) { + return fmt.Sprintf("http %d", wsErr.StatusCode) + } + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return "timeout" + } + return err.Error() +} + +func isHTTPStatusError(err error, statusCode int) bool { + var wsErr *WsHandshakeError + return errors.As(err, &wsErr) && wsErr.StatusCode == statusCode +} + +func logCfConnError(format string, err error, args ...any) { + if isHTTPStatusError(err, http.StatusTooManyRequests) { + logWarn.Printf(format, args...) + return + } + logError.Printf(format, args...) +} + +func wsConnectOnce(ctx context.Context, dialAddr, domain, path string, timeout time.Duration) (*RawWebSocket, error) { + if dialAddr == "" { + return nil, fmt.Errorf("empty dial address") + } + + dialer := &net.Dialer{ + Timeout: timeout, + } + + tlsCfg := tlsConfigPool.Clone() + tlsCfg.ServerName = domain + tlsCfg.InsecureSkipVerify = true + + targetAddr := net.JoinHostPort(dialAddr, "443") + rawConn, err := dialer.DialContext(ctx, "tcp", targetAddr) + if err != nil { + return nil, err + } + + setSockOpts(rawConn) + + tlsConn := tls.Client(rawConn, tlsCfg) + handshakeTimeout := wsHandshakeTimeout(timeout) + handshakeCtx, cancel := context.WithTimeout(ctx, handshakeTimeout) + defer cancel() + + _ = tlsConn.SetDeadline(time.Now().Add(handshakeTimeout)) + if err := tlsConn.HandshakeContext(handshakeCtx); err != nil { + rawConn.Close() + logDebug.Printf(" ws tls fail %s via %s: %s", domain, dialAddr, compactConnError(err)) + return nil, err + } + _ = tlsConn.SetDeadline(time.Time{}) + rawConn = tlsConn + + 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"+ + "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(timeout)) + if _, err = rawConn.Write([]byte(req)); err != nil { + rawConn.Close() + return nil, err + } + _ = rawConn.SetWriteDeadline(time.Time{}) + + bufReader := bufio.NewReaderSize(rawConn, 4096) + _ = rawConn.SetReadDeadline(time.Now().Add(timeout)) + + 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 { + return &RawWebSocket{conn: rawConn, bufReader: bufReader}, nil + } + headers := make(map[string]string) + for _, hl := range responseLines[1:] { + if idx := strings.IndexByte(hl, ':'); idx >= 0 { + headers[strings.TrimSpace(strings.ToLower(hl[:idx]))] = strings.TrimSpace(hl[idx+1:]) + } + } + rawConn.Close() + return nil, &WsHandshakeError{ + StatusCode: statusCode, + StatusLine: firstLine, + Headers: headers, + Location: headers["location"], + } +} + +func cfConnectDomain(ctx context.Context, domain, path string, timeout float64) (*RawWebSocket, string, error) { + if path == "" { + path = "/apiws" + } + + attemptTimeout := wsConnectTimeout(timeout) + phaseTimeout := attemptTimeout + if phaseTimeout > cfproxyDialPhaseTimeout { + phaseTimeout = cfproxyDialPhaseTimeout + } + + hostCtx, cancelHost, hostTimeout := newTimedAttemptContext(ctx, phaseTimeout) + ws, hostErr := wsConnectOnce(hostCtx, domain, domain, path, hostTimeout) + cancelHost() + if hostErr == nil { + return ws, "", nil + } + if isHTTPStatusError(hostErr, http.StatusTooManyRequests) { + return nil, "", hostErr + } + if ctx.Err() != nil { + return nil, "", hostErr + } + + resolvedIP := resolveDoH(ctx, domain) + if resolvedIP == "" { + logDebug.Printf(" CF DNS %s -> no result", domain) + return nil, "", hostErr + } + + logDebug.Printf(" CF DNS %s -> %s", domain, resolvedIP) + ipCtx, cancelIP, ipTimeout := newTimedAttemptContext(ctx, phaseTimeout) + ws, err := wsConnectOnce(ipCtx, resolvedIP, domain, path, ipTimeout) + cancelIP() + if err == nil { + return ws, resolvedIP, nil + } + if ctx.Err() == nil { + logCfConnError(" CF IP fail %s (%s): %s", err, domain, resolvedIP, compactConnError(err)) + } + return nil, resolvedIP, err +} + +func wsConnect(ctx context.Context, ip, domain, path string, timeout float64) (*RawWebSocket, error) { + if path == "" { + path = "/apiws" + } + + attemptTimeout := wsConnectTimeout(timeout) + attemptCtx, cancel := context.WithTimeout(ctx, attemptTimeout) + defer cancel() + + primaryAddr := strings.TrimSpace(ip) + if primaryAddr == "" { + primaryAddr = domain + } + + ws, err := wsConnectOnce(attemptCtx, primaryAddr, domain, path, attemptTimeout) + if err == nil { + return ws, nil + } + + if primaryAddr == domain && net.ParseIP(primaryAddr) == nil { + if resolvedIP := resolveDoH(attemptCtx, domain); resolvedIP != "" && resolvedIP != primaryAddr { + return wsConnectOnce(attemptCtx, resolvedIP, domain, path, attemptTimeout) + } + } + + return nil, err +} + +func connectDirectWS(ctx context.Context, target string, domains []string, timeout float64) (*RawWebSocket, bool, bool) { + if len(domains) == 0 { + return nil, false, false + } + + wsFailedRedirect := false + allRedirects := true + + for _, dom := range domains { + ws, err := wsConnect(ctx, target, dom, "/apiws", timeout) + if err == nil { + return ws, wsFailedRedirect, false + } + + stats.wsErrors.Add(1) + var wsErr *WsHandshakeError + if errors.As(err, &wsErr) { + if wsErr.IsRedirect() { + wsFailedRedirect = true + } else { + allRedirects = false + } + } else { + allRedirects = false + } + } + + return nil, wsFailedRedirect, allRedirects +} + +func (ws *RawWebSocket) writeFrame(frame []byte, timeout time.Duration) error { + ws.writeMu.Lock() + defer ws.writeMu.Unlock() + defer recycleFrame(frame) + + if timeout > 0 { + _ = ws.conn.SetWriteDeadline(time.Now().Add(timeout)) + defer ws.conn.SetWriteDeadline(time.Time{}) + } + + _, err := ws.conn.Write(frame) + if err != nil { + ws.closed.Store(true) + } + return err +} + +func (ws *RawWebSocket) Send(data []byte) error { + if ws.closed.Load() { + return fmt.Errorf("WebSocket closed") + } + frame := ws.buildFrame(opBinary, data, true) + return ws.writeFrame(frame, wsWriteTimeout) +} + +func (ws *RawWebSocket) SendBatch(parts [][]byte) error { + if ws.closed.Load() { + return fmt.Errorf("WebSocket closed") + } + ws.writeMu.Lock() + defer ws.writeMu.Unlock() + _ = ws.conn.SetWriteDeadline(time.Now().Add(wsWriteTimeout)) + defer ws.conn.SetWriteDeadline(time.Time{}) + for _, part := range parts { + frame := ws.buildFrame(opBinary, part, true) + if _, err := ws.conn.Write(frame); err != nil { + recycleFrame(frame) + ws.closed.Store(true) + return err + } + recycleFrame(frame) + } + return nil +} + +func (ws *RawWebSocket) SendPing() error { + if ws.closed.Load() { + return fmt.Errorf("WebSocket closed") + } + frame := ws.buildFrame(opPing, nil, true) + return ws.writeFrame(frame, wsControlTimeout) +} + +func (ws *RawWebSocket) probe(timeout time.Duration) error { + if ws.closed.Load() { + return fmt.Errorf("WebSocket closed") + } + if err := ws.SendPing(); err != nil { + return err + } + _ = ws.conn.SetReadDeadline(time.Now().Add(timeout)) + defer ws.conn.SetReadDeadline(time.Time{}) + + for !ws.closed.Load() { + opcode, payload, err := ws.readFrame() + if err != nil { + ws.closed.Store(true) + return err + } + switch opcode { + case opPong: + return nil + case opPing: + if err := ws.writeFrame(ws.buildFrame(opPong, payload, true), wsControlTimeout); err != nil { + return err + } + case opClose: + ws.closed.Store(true) + return io.EOF + default: + return fmt.Errorf("unexpected frame %d during pool probe", opcode) + } + } + return io.EOF +} + +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.writeFrame(reply, wsControlTimeout) + return nil, io.EOF + case opPing: + pong := ws.buildFrame(opPong, payload, true) + _ = ws.writeFrame(pong, wsControlTimeout) + continue + case opText, opBinary: + return payload, nil + } + } + return nil, io.EOF +} + +func (ws *RawWebSocket) Close() { + if ws.closed.Swap(true) { + return + } + frame := ws.buildFrame(opClose, nil, true) + _ = ws.writeFrame(frame, wsControlTimeout) + _ = ws.conn.Close() +} + +var framePool = sync.Pool{ + New: func() any { return make([]byte, 0, pooledFrameCap) }, +} + +func recycleFrame(frame []byte) { + if cap(frame) == pooledFrameCap { + framePool.Put(frame[:0]) + } +} + +func (ws *RawWebSocket) buildFrame(opcode int, data []byte, mask bool) []byte { + length := len(data) + fb := byte(0x80 | opcode) + + headerSize := 2 + if mask { + headerSize += 4 + } + if length >= 126 && length < 65536 { + headerSize += 2 + } else if length >= 65536 { + headerSize += 8 + } + + totalSize := headerSize + length + var result []byte + if totalSize <= pooledFrameCap { + result = framePool.Get().([]byte)[:0] + } else { + result = make([]byte, 0, totalSize) + } + result = result[: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 + 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) { + var hdr [2]byte + 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 { + var buf [2]byte + if _, err := io.ReadFull(ws.bufReader, buf[:]); err != nil { + return 0, nil, err + } + length = uint64(binary.BigEndian.Uint16(buf[:])) + } else if length == 127 { + var buf [8]byte + 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 [4]byte + if hasMask { + if _, err := io.ReadFull(ws.bufReader, maskKey[:]); err != nil { + return 0, nil, err + } + } + + const maxFramePayload = 16 * 1024 * 1024 + if length > maxFramePayload { + return 0, nil, fmt.Errorf("frame too large: %d bytes", length) + } + 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 & MTProto Splitter +// --------------------------------------------------------------------------- + +type TrackedStream struct { + key []byte + iv []byte + processed uint64 + stream cipher.Stream +} + +func newTrackedCTR(key, iv []byte) (*TrackedStream, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + return &TrackedStream{ + key: append([]byte(nil), key...), + iv: append([]byte(nil), iv...), + processed: 0, + stream: cipher.NewCTR(block, iv), + }, nil +} + +func (t *TrackedStream) XORKeyStream(dst, src []byte) { + t.stream.XORKeyStream(dst, src) + t.processed += uint64(len(src)) +} + +func (t *TrackedStream) Clone() cipher.Stream { + block, _ := aes.NewCipher(t.key) + cloneStream := cipher.NewCTR(block, t.iv) + tClone := &TrackedStream{ + key: t.key, + iv: t.iv, + processed: t.processed, + stream: cloneStream, + } + var dummy [16384]byte + rem := t.processed + for rem > 0 { + n := rem + if n > 16384 { + n = 16384 + } + tClone.stream.XORKeyStream(dummy[:n], dummy[:n]) + rem -= n + } + return tClone +} + +func newAESCTR(key, iv []byte) (cipher.Stream, error) { + return newTrackedCTR(key, iv) +} + +const ( + protoAbridged = 0 + protoIntermediate = 1 + protoPaddedIntermediate = 2 +) + +type MsgSplitter struct { + stream cipher.Stream + protoType int + cipherBuf []byte + plainBuf []byte + disabled bool +} + +func protoTagToType(proto uint32) int { + switch proto { + case 0xEEEEEEEE: + return protoIntermediate + case 0xDDDDDDDD: + return protoPaddedIntermediate + default: + return protoAbridged + } +} + +func newMsgSplitter(initData []byte, proto uint32) (*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, + protoType: protoTagToType(proto), + }, nil +} + +func (s *MsgSplitter) Split(chunk []byte) [][]byte { + if len(chunk) == 0 { + return nil + } + if s.disabled { + return [][]byte{chunk} + } + + s.cipherBuf = append(s.cipherBuf, chunk...) + decrypted := make([]byte, len(chunk)) + s.stream.XORKeyStream(decrypted, chunk) + s.plainBuf = append(s.plainBuf, decrypted...) + + var parts [][]byte + for len(s.cipherBuf) > 0 { + pktLen := s.nextPacketLen() + if pktLen < 0 { + break + } + if pktLen == 0 { + parts = append(parts, append([]byte(nil), s.cipherBuf...)) + s.cipherBuf = nil + s.plainBuf = nil + s.disabled = true + break + } + if len(s.cipherBuf) < pktLen { + break + } + parts = append(parts, append([]byte(nil), s.cipherBuf[:pktLen]...)) + s.cipherBuf = s.cipherBuf[pktLen:] + s.plainBuf = s.plainBuf[pktLen:] + } + + if len(s.cipherBuf) == 0 { + s.cipherBuf = nil + s.plainBuf = nil + } + if len(parts) == 0 { + return nil + } + return parts +} + +func (s *MsgSplitter) Flush() [][]byte { + if len(s.cipherBuf) == 0 { + return nil + } + tail := append([]byte(nil), s.cipherBuf...) + s.cipherBuf = nil + s.plainBuf = nil + return [][]byte{tail} +} + +func (s *MsgSplitter) nextPacketLen() int { + if len(s.plainBuf) == 0 { + return -1 + } + switch s.protoType { + case protoAbridged: + first := s.plainBuf[0] & 0x7F + var headerLen, payloadLen int + if first == 0x7F { + if len(s.plainBuf) < 4 { + return -1 + } + payloadLen = int(uint32(s.plainBuf[1])|uint32(s.plainBuf[2])<<8|uint32(s.plainBuf[3])<<16) * 4 + headerLen = 4 + } else { + payloadLen = int(first) * 4 + headerLen = 1 + } + if payloadLen <= 0 { + return 0 + } + pktLen := headerLen + payloadLen + if len(s.plainBuf) < pktLen { + return -1 + } + return pktLen + + case protoIntermediate, protoPaddedIntermediate: + if len(s.plainBuf) < 4 { + return -1 + } + payloadLen := int(binary.LittleEndian.Uint32(s.plainBuf[:4]) & 0x7FFFFFFF) + if payloadLen <= 0 { + return 0 + } + pktLen := 4 + payloadLen + if len(s.plainBuf) < pktLen { + return -1 + } + return pktLen + } + return 0 +} + +// --------------------------------------------------------------------------- +// WsPool & Bridging +// --------------------------------------------------------------------------- + +func wsDomains(dc int, isMedia bool) []string { + effectiveDC := dc + if override, ok := dcOverrides[dc]; ok { + effectiveDC = override + } + + if 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), + } +} + +type dcSlot struct { + dc int + isMedia int +} + +type poolEntry struct { + ws *RawWebSocket + created int64 +} + +type WsPool struct { + queues sync.Map + status sync.Map +} + +func newWsPool() *WsPool { return &WsPool{} } + +func (p *WsPool) getQueue(slot dcSlot) (chan *poolEntry, *atomic.Int32) { + q, _ := p.queues.LoadOrStore(slot, make(chan *poolEntry, 16)) // Max size safely handled + s, _ := p.status.LoadOrStore(slot, &atomic.Int32{}) + return q.(chan *poolEntry), s.(*atomic.Int32) +} + +func isMediaInt(b bool) int { + if b { + return 1 + } + return 0 +} + +func isPoolEntryUsable(e *poolEntry, now int64) bool { + if e == nil || e.ws == nil || e.ws.closed.Load() { + return false + } + if now-e.created > int64(wsPoolReuseMaxAge) { + return false + } + return true +} + +func (p *WsPool) Get(ctx context.Context, dc int, isMedia bool, targetIP string, domains []string) *RawWebSocket { + slot := dcSlot{dc, isMediaInt(isMedia)} + q, s := p.getQueue(slot) + now := time.Now().Unix() + var ws *RawWebSocket + + for { + select { + case e := <-q: + if !isPoolEntryUsable(e, now) { + if e != nil && e.ws != nil { + SafeClose(e.ws.conn) + } + continue + } + ws = e.ws + stats.poolHits.Add(1) + default: + stats.poolMisses.Add(1) + } + break + } + + if s.CompareAndSwap(0, 1) { + go p.refill(ctx, slot, q, s, targetIP, domains) + } + return ws +} + +func (p *WsPool) refill(ctx context.Context, slot dcSlot, q chan *poolEntry, s *atomic.Int32, targetIP string, domains []string) { + defer s.Store(0) + needed := int(poolSize.Load()) - len(q) + if needed <= 0 { + return + } + + var wg sync.WaitGroup + for i := 0; i < needed; i++ { + select { + case <-ctx.Done(): + return + default: + } + wg.Add(1) + go func() { + defer wg.Done() + if ws := connectOneWS(ctx, targetIP, domains); ws != nil { + now := time.Now().Unix() + select { + case q <- &poolEntry{ws: ws, created: now}: + case <-ctx.Done(): + SafeClose(ws.conn) + default: + SafeClose(ws.conn) + } + } + }() + } + wg.Wait() +} + +func (p *WsPool) Warmup(ctx context.Context, dcOptMap map[int]string) { + for dc, targetIP := range dcOptMap { + if targetIP == "" { + continue + } + for _, isMedia := range []bool{false, true} { + select { + case <-ctx.Done(): + return + default: + } + domains := wsDomains(dc, isMedia) + slot := dcSlot{dc, isMediaInt(isMedia)} + q, s := p.getQueue(slot) + if s.CompareAndSwap(0, 1) { + go p.refill(ctx, slot, q, s, targetIP, domains) + } + } + } +} + +func (p *WsPool) IdleCount() int { + count := 0 + p.queues.Range(func(_, val interface{}) bool { + count += len(val.(chan *poolEntry)) + return true + }) + return count +} + +func (p *WsPool) CloseAll() { + p.queues.Range(func(_, val interface{}) bool { + q := val.(chan *poolEntry) + for { + select { + case e := <-q: + SafeClose(e.ws.conn) + default: + return true + } + } + }) +} + +var wsPool = newWsPool() + +func mediaTag(isMedia bool) string { + if isMedia { + return "m" + } + return "" +} + +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" +} + +func bridgeWS(ctx context.Context, conn net.Conn, ws *RawWebSocket, + label string, dc int, dst string, port int, isMedia bool, + splitter *MsgSplitter, cltDec, cltEnc, tgEnc, tgDec cipher.Stream) { + + ctx2, cancel := context.WithCancel(ctx) + defer cancel() + + go func() { + <-ctx2.Done() + SafeClose(conn) + ws.Close() + }() + + var wg sync.WaitGroup + wg.Add(2) + + // WS keepalive: periodic ping to detect dead connections + lastActivity := time.Now() + var activityMu sync.Mutex + + go func() { + ticker := time.NewTicker(bridgePingInterval) + defer ticker.Stop() + for { + select { + case <-ctx2.Done(): + return + case <-ticker.C: + activityMu.Lock() + idle := time.Since(lastActivity) + activityMu.Unlock() + if idle > bridgePingInterval { + if err := ws.SendPing(); err != nil { + cancel() + return + } + } + } + } + }() + + go func() { + defer wg.Done() + defer cancel() + buf := bytesPool.Get().([]byte) + defer bytesPool.Put(buf) + readLimit := cap(buf) + if readLimit > wsBridgeChunkSize { + readLimit = wsBridgeChunkSize + } + for { + _ = conn.SetReadDeadline(time.Now().Add(bridgeReadTimeout)) + n, err := conn.Read(buf[:readLimit]) + if n > 0 { + chunk := buf[:n] + stats.bytesUp.Add(int64(n)) + + activityMu.Lock() + lastActivity = time.Now() + activityMu.Unlock() + + cltDec.XORKeyStream(chunk, chunk) + tgEnc.XORKeyStream(chunk, chunk) + + var sendErr error + if splitter != nil { + parts := splitter.Split(chunk) + if len(parts) > 1 { + sendErr = ws.SendBatch(parts) + } else if len(parts) == 1 { + sendErr = ws.Send(parts[0]) + } + } else { + sendErr = ws.Send(chunk) + } + if sendErr != nil { + return + } + } + if err != nil { + if splitter != nil { + tail := splitter.Flush() + if len(tail) > 0 { + if len(tail) > 1 { + if sendErr := ws.SendBatch(tail); sendErr != nil { + return + } + } else { + if sendErr := ws.Send(tail[0]); sendErr != nil { + return + } + } + } + } + return + } + } + }() + + go func() { + defer wg.Done() + defer cancel() + for { + _ = ws.conn.SetReadDeadline(time.Now().Add(bridgeReadTimeout)) + data, err := ws.Recv() + if err != nil || data == nil { + return + } + n := len(data) + stats.bytesDown.Add(int64(n)) + + activityMu.Lock() + lastActivity = time.Now() + activityMu.Unlock() + + tgDec.XORKeyStream(data, data) + cltEnc.XORKeyStream(data, data) + if _, werr := conn.Write(data); werr != nil { + return + } + } + }() + + wg.Wait() +} + +func bridgeTCP(ctx context.Context, client, remote net.Conn, + label string, dc int, dst string, port int, isMedia bool, cltDec, cltEnc, tgEnc, tgDec cipher.Stream) { + + ctx2, cancel := context.WithCancel(ctx) + + go func() { + <-ctx2.Done() + SafeClose(client) + SafeClose(remote) + }() + + var wg sync.WaitGroup + wg.Add(2) + + forward := func(src, dstW net.Conn, isUp bool) { + defer wg.Done() + defer cancel() + buf := bytesPool.Get().([]byte) + defer bytesPool.Put(buf) + for { + _ = src.SetReadDeadline(time.Now().Add(bridgeReadTimeout)) + n, err := src.Read(buf[:cap(buf)]) + if n > 0 { + chunk := buf[:n] + if isUp { + stats.bytesUp.Add(int64(n)) + cltDec.XORKeyStream(chunk, chunk) + tgEnc.XORKeyStream(chunk, chunk) + } else { + stats.bytesDown.Add(int64(n)) + tgDec.XORKeyStream(chunk, chunk) + cltEnc.XORKeyStream(chunk, chunk) + } + if _, werr := dstW.Write(chunk); werr != nil { + return + } + } + if err != nil { + return + } + } + } + + go forward(client, remote, true) + go forward(remote, client, false) + + wg.Wait() +} + +func tcpFallback(ctx context.Context, client net.Conn, dst string, port int, + init []byte, label string, dc int, isMedia bool, cltDec, cltEnc, tgEnc, tgDec cipher.Stream) bool { + + dialer := &net.Dialer{ + Timeout: 10 * time.Second, + KeepAlive: 60 * time.Second, + } + remote, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(dst, strconv.Itoa(port))) + if err != nil { + return false + } + + stats.connectionsTcpFallback.Add(1) + logInfo.Printf(" DC%d%s подключен по TCP", dc, mediaTag(isMedia)) + _, _ = remote.Write(init) + bridgeTCP(ctx, client, remote, label, dc, dst, port, isMedia, cltDec, cltEnc, tgEnc, tgDec) + return true +} + +func tryCfproxyBaseDomain(ctx context.Context, dc int, baseDomain string) (*RawWebSocket, string) { + baseDomain = normalizeCfDomain(baseDomain) + if baseDomain == "" { + return nil, "" + } + if remaining := cfproxy429CooldownRemaining(baseDomain); remaining > 0 { + logDebug.Printf(" CF skip %s: 429 cooldown %.0fs", baseDomain, math.Ceil(remaining.Seconds())) + return nil, "" + } + if !acquireCfproxyAttemptSlot(ctx) { + return nil, "" + } + defer releaseCfproxyAttemptSlot() + + domain := fmt.Sprintf("kws%d.%s", dc, baseDomain) + logDebug.Printf(" CF try %s", domain) + + ws, resolvedIP, err := cfConnectDomain(ctx, domain, "/apiws", 5) + if err != nil { + if ctx.Err() == nil && isHTTPStatusError(err, http.StatusTooManyRequests) { + markCfproxy429Cooldown(baseDomain, err) + } + if ctx.Err() == nil { + if resolvedIP != "" { + logCfConnError(" CF fail %s via %s: %s", err, domain, resolvedIP, compactConnError(err)) + } else { + logCfConnError(" CF fail %s: %s", err, domain, compactConnError(err)) + } + } + return nil, "" + } + + clearCfproxy429Cooldown(baseDomain) + if resolvedIP != "" { + logDebug.Printf(" CF ok %s via %s", domain, resolvedIP) + } else { + logDebug.Printf(" CF ok %s via hostname", domain) + } + return ws, baseDomain +} + +func cfproxyFallback(ctx context.Context, conn net.Conn, relayInit []byte, label string, + dc int, isMedia bool, splitter *MsgSplitter, + cltDec, cltEnc, tgEnc, tgDec cipher.Stream) bool { + + cfproxyMu.RLock() + if !cfproxyEnabled || len(cfproxyDomains) == 0 { + cfproxyMu.RUnlock() + return false + } + active := activeCfDomain + domains := make([]string, len(cfproxyDomains)) + copy(domains, cfproxyDomains) + cfproxyMu.RUnlock() + + ordered := []string{active} + for _, d := range domains { + if d != active { + ordered = append(ordered, d) + } + } + + mTag := mediaTag(isMedia) + logDebug.Printf(" CF fallback DC%d%s: %d домен(ов)", dc, mTag, len(ordered)) + + var ws *RawWebSocket + var chosenDomain string + + if len(ordered) > 0 && ordered[0] != "" { + ws, chosenDomain = tryCfproxyBaseDomain(ctx, dc, ordered[0]) + } + + if ws == nil && len(ordered) > 1 { + remainingDomains := ordered[1:] + + type wsResult struct { + ws *RawWebSocket + domain string + } + attemptCtx, cancelAttempts := context.WithCancel(ctx) + defer cancelAttempts() + + ch := make(chan wsResult, len(remainingDomains)) + sem := make(chan struct{}, cfproxyFallbackParallel) + for _, baseDomain := range remainingDomains { + go func(bd string) { + select { + case sem <- struct{}{}: + case <-attemptCtx.Done(): + ch <- wsResult{} + return + } + defer func() { <-sem }() + + nextWS, nextDomain := tryCfproxyBaseDomain(attemptCtx, dc, bd) + if nextWS != nil { + select { + case ch <- wsResult{ws: nextWS, domain: nextDomain}: + case <-attemptCtx.Done(): + go nextWS.Close() + ch <- wsResult{} + } + return + } + ch <- wsResult{} + }(baseDomain) + } + + for i := 0; i < len(remainingDomains); i++ { + r := <-ch + if r.ws != nil && ws == nil { + ws = r.ws + chosenDomain = r.domain + cancelAttempts() + remaining := len(remainingDomains) - i - 1 + if remaining > 0 { + go func(left int) { + for j := 0; j < left; j++ { + rr := <-ch + if rr.ws != nil { + go rr.ws.Close() + } + } + }(remaining) + } + break + } else if r.ws != nil { + go r.ws.Close() + } + } + } + + if ws == nil { + logWarn.Printf(" CF fallback DC%d%s: все CF домены недоступны", dc, mTag) + return false + } + + if chosenDomain != "" && chosenDomain != active { + cfproxyMu.Lock() + activeCfDomain = chosenDomain + cfproxyMu.Unlock() + saveActiveCfproxyDomain(chosenDomain) + logInfo.Printf(" CF домен %s", chosenDomain) + } + + stats.connectionsCfproxy.Add(1) + logInfo.Printf(" DC%d%s подключен через CF", dc, mTag) + + if err := ws.Send(relayInit); err != nil { + ws.Close() + return false + } + + bridgeWS(ctx, conn, ws, label, dc, chosenDomain, 443, isMedia, splitter, cltDec, cltEnc, tgEnc, tgDec) + return true +} + +func doFallback(ctx context.Context, conn net.Conn, relayInit []byte, label string, + dc int, isMedia bool, splitter *MsgSplitter, + cltDec, cltEnc, tgEnc, tgDec cipher.Stream) bool { + + if t, ok := cltDec.(interface{ Clone() cipher.Stream }); ok { + cltDec = t.Clone() + } + if t, ok := cltEnc.(interface{ Clone() cipher.Stream }); ok { + cltEnc = t.Clone() + } + if t, ok := tgEnc.(interface{ Clone() cipher.Stream }); ok { + tgEnc = t.Clone() + } + if t, ok := tgDec.(interface{ Clone() cipher.Stream }); ok { + tgDec = t.Clone() + } + + fallbackDst := resolveFallbackTarget(dc, isMedia) + + cfproxyMu.RLock() + useCf := cfproxyEnabled + cfproxyMu.RUnlock() + + if useCf { + if cfproxyFallback(ctx, conn, relayInit, label, dc, isMedia, splitter, cltDec, cltEnc, tgEnc, tgDec) { + return true + } + } + + if fallbackDst != "" { + if tcpFallback(ctx, conn, fallbackDst, 443, relayInit, label, dc, isMedia, cltDec, cltEnc, tgEnc, tgDec) { + return true + } + } + + return false +} + +// --------------------------------------------------------------------------- +// Fake TLS support (ee-secret) +// --------------------------------------------------------------------------- + +const ( + tlsRecordHandshake = 0x16 + tlsRecordCCS = 0x14 + tlsRecordAppData = 0x17 + clientRandomOffset = 11 + clientRandomLen = 32 + sessionIdOffset = 44 + sessionIdLen = 32 + timestampTolerance = 120 +) + +func verifyClientHello(data, secret []byte) ([]byte, []byte, bool) { + n := len(data) + if n < 43 { + return nil, nil, false + } + if data[0] != tlsRecordHandshake || data[5] != 0x01 { + return nil, nil, false + } + + clientRandom := make([]byte, clientRandomLen) + copy(clientRandom, data[clientRandomOffset:clientRandomOffset+clientRandomLen]) + + zeroed := make([]byte, n) + copy(zeroed, data) + for i := 0; i < clientRandomLen; i++ { + zeroed[clientRandomOffset+i] = 0 + } + + mac := hmacSHA256(secret, zeroed) + + for i := 0; i < 28; i++ { + if mac[i] != clientRandom[i] { + return nil, nil, false + } + } + + tsXor := make([]byte, 4) + for i := 0; i < 4; i++ { + tsXor[i] = clientRandom[28+i] ^ mac[28+i] + } + timestamp := binary.LittleEndian.Uint32(tsXor) + now := uint32(time.Now().Unix()) + diff := int64(now) - int64(timestamp) + if diff < 0 { + diff = -diff + } + if diff > timestampTolerance { + return nil, nil, false + } + + sessionId := make([]byte, sessionIdLen) + if n >= sessionIdOffset+sessionIdLen && data[43] == 0x20 { + copy(sessionId, data[sessionIdOffset:sessionIdOffset+sessionIdLen]) + } + + return clientRandom, sessionId, true +} + +func hmacSHA256(key, data []byte) []byte { + h := hmac.New(sha256.New, key) + h.Write(data) + return h.Sum(nil) +} + +var serverHelloTemplate = []byte{ + 0x16, 0x03, 0x03, 0x00, 0x7a, 0x02, 0x00, 0x00, 0x76, 0x03, 0x03, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0x20, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0x13, 0x01, 0x00, 0x00, 0x2e, 0x00, 0x33, 0x00, 0x24, 0x00, 0x1d, 0x00, 0x20, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0x00, 0x2b, 0x00, 0x02, 0x03, 0x04, +} + +func buildServerHello(secret, clientRandom, sessionId []byte) []byte { + sh := make([]byte, len(serverHelloTemplate)) + copy(sh, serverHelloTemplate) + copy(sh[44:44+32], sessionId) + + pubKey := make([]byte, 32) + rand.Read(pubKey) + copy(sh[89:89+32], pubKey) + + ccsFrame := []byte{0x14, 0x03, 0x03, 0x00, 0x01, 0x01} + + encSize := 1900 + int(time.Now().UnixNano()%200) + encData := make([]byte, encSize) + rand.Read(encData) + appRecord := make([]byte, 5+encSize) + appRecord[0] = 0x17 + appRecord[1] = 0x03 + appRecord[2] = 0x03 + binary.BigEndian.PutUint16(appRecord[3:5], uint16(encSize)) + copy(appRecord[5:], encData) + + response := make([]byte, 0, len(sh)+len(ccsFrame)+len(appRecord)) + response = append(response, sh...) + response = append(response, ccsFrame...) + response = append(response, appRecord...) + + hmacInput := make([]byte, 0, len(clientRandom)+len(response)) + hmacInput = append(hmacInput, clientRandom...) + hmacInput = append(hmacInput, response...) + serverRandom := hmacSHA256(secret, hmacInput) + + copy(response[11:11+32], serverRandom) + + return response +} + +type FakeTlsConn struct { + conn net.Conn + readLeft int +} + +func newFakeTlsConn(conn net.Conn) *FakeTlsConn { + return &FakeTlsConn{conn: conn} +} + +func (f *FakeTlsConn) Read(p []byte) (int, error) { + if f.readLeft > 0 { + toRead := f.readLeft + if toRead > len(p) { + toRead = len(p) + } + n, err := f.conn.Read(p[:toRead]) + f.readLeft -= n + return n, err + } + + for { + hdr := make([]byte, 5) + if _, err := io.ReadFull(f.conn, hdr); err != nil { + return 0, err + } + + rtype := hdr[0] + recLen := int(binary.BigEndian.Uint16(hdr[3:5])) + + if rtype == tlsRecordCCS { + if recLen > 0 { + discard := make([]byte, recLen) + if _, err := io.ReadFull(f.conn, discard); err != nil { + return 0, err + } + } + continue + } + + if rtype != tlsRecordAppData { + return 0, fmt.Errorf("unexpected TLS record type 0x%02X", rtype) + } + + toRead := recLen + if toRead > len(p) { + toRead = len(p) + } + n, err := f.conn.Read(p[:toRead]) + f.readLeft = recLen - n + return n, err + } +} + +func (f *FakeTlsConn) Write(p []byte) (int, error) { + var parts []byte + offset := 0 + for offset < len(p) { + end := offset + 16384 + if end > len(p) { + end = len(p) + } + chunk := p[offset:end] + hdr := []byte{0x17, 0x03, 0x03, 0, 0} + binary.BigEndian.PutUint16(hdr[3:5], uint16(len(chunk))) + parts = append(parts, hdr...) + parts = append(parts, chunk...) + offset = end + } + _, err := f.conn.Write(parts) + return len(p), err +} + +func (f *FakeTlsConn) Close() error { return f.conn.Close() } +func (f *FakeTlsConn) LocalAddr() net.Addr { return f.conn.LocalAddr() } +func (f *FakeTlsConn) RemoteAddr() net.Addr { return f.conn.RemoteAddr() } +func (f *FakeTlsConn) SetDeadline(t time.Time) error { return f.conn.SetDeadline(t) } +func (f *FakeTlsConn) SetReadDeadline(t time.Time) error { return f.conn.SetReadDeadline(t) } +func (f *FakeTlsConn) SetWriteDeadline(t time.Time) error { return f.conn.SetWriteDeadline(t) } + +// PrefixConn solves the 1/256 disconnect bug gracefully +type PrefixConn struct { + net.Conn + prefix []byte +} + +func (c *PrefixConn) Read(p []byte) (int, error) { + if len(c.prefix) > 0 { + n := copy(p, c.prefix) + c.prefix = c.prefix[n:] + return n, nil + } + return c.Conn.Read(p) +} + +func handleClient(ctx context.Context, conn net.Conn) { + stats.connectionsTotal.Add(1) + stats.connectionsActive.Add(1) + defer func() { + if stats.connectionsActive.Load() > 0 { + stats.connectionsActive.Add(-1) + } + }() + peer := conn.RemoteAddr().String() + label := peer + + setSockOpts(conn) + + defer conn.Close() + + proxySecretMu.RLock() + currentSecret := proxySecret + proxySecretMu.RUnlock() + secretBytes, _ := hex.DecodeString(currentSecret) + + fakeTlsMu.RLock() + useFakeTls := fakeTlsEnabled + fakeTlsMu.RUnlock() + + firstByte := make([]byte, 1) + _ = conn.SetReadDeadline(time.Now().Add(10 * time.Second)) + if _, err := io.ReadFull(conn, firstByte); err != nil { + return + } + _ = conn.SetReadDeadline(time.Time{}) + + var clientConn net.Conn = conn + var handshake []byte + + if useFakeTls && firstByte[0] == tlsRecordHandshake { + hdrRest := make([]byte, 4) + if _, err := io.ReadFull(conn, hdrRest); err != nil { + return + } + tlsHeader := append(firstByte, hdrRest...) + recordLen := int(binary.BigEndian.Uint16(tlsHeader[3:5])) + + if recordLen > 16384 { + // Not TLS, gracefully fallback + clientConn = &PrefixConn{Conn: conn, prefix: tlsHeader} + } else { + recordBody := make([]byte, recordLen) + if _, err := io.ReadFull(conn, recordBody); err != nil { + return + } + clientHello := append(tlsHeader, recordBody...) + clientRandom, sessionId, ok := verifyClientHello(clientHello, secretBytes) + if !ok { + // FakeTLS failed, fallback gracefully (fixes 1/256 disconnect bug) + clientConn = &PrefixConn{Conn: conn, prefix: clientHello} + } else { + serverHello := buildServerHello(secretBytes, clientRandom, sessionId) + if _, err := conn.Write(serverHello); err != nil { + return + } + clientConn = newFakeTlsConn(conn) + } + } + } else { + clientConn = &PrefixConn{Conn: conn, prefix: firstByte} + } + + handshake = make([]byte, 64) + _ = clientConn.SetReadDeadline(time.Now().Add(5 * time.Second)) + if _, err := io.ReadFull(clientConn, handshake); err != nil { + return + } + _ = clientConn.SetReadDeadline(time.Time{}) + + if isHTTPTransport(handshake) { + stats.connectionsHttpReject.Add(1) + _, _ = conn.Write([]byte("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n")) + return + } + + cltDecPrekey := handshake[8:40] + cltDecIv := handshake[40:56] + hashDec := sha256.New() + hashDec.Write(cltDecPrekey) + hashDec.Write(secretBytes) + cltDecryptor, _ := newAESCTR(hashDec.Sum(nil), cltDecIv) + + decrypted := make([]byte, 64) + cltDecryptor.XORKeyStream(decrypted, handshake) + + protoTag := decrypted[56:60] + proto := binary.LittleEndian.Uint32(protoTag) + if !validProtos[proto] { + stats.connectionsBad.Add(1) + return + } + + dcRaw := int16(binary.LittleEndian.Uint16(decrypted[60:62])) + dc := int(dcRaw) + if dc < 0 { + dc = -dc + } + isMedia := dcRaw < 0 + mTag := mediaTag(isMedia) + + cltEncPrekeyAndIv := make([]byte, 48) + for i := 0; i < 48; i++ { + cltEncPrekeyAndIv[i] = handshake[8+47-i] + } + hashEnc := sha256.New() + hashEnc.Write(cltEncPrekeyAndIv[:32]) + hashEnc.Write(secretBytes) + cltEncryptor, _ := newAESCTR(hashEnc.Sum(nil), cltEncPrekeyAndIv[32:]) + + relayInit := make([]byte, 64) + for { + rand.Read(relayInit) + if relayInit[0] == 0xEF { + continue + } + s := string(relayInit[:4]) + if s == "HEAD" || s == "POST" || s == "GET " || s == "\xee\xee\xee\xee" || s == "\xdd\xdd\xdd\xdd" { + continue + } + if relayInit[0] == 0x16 && relayInit[1] == 0x03 && relayInit[2] == 0x01 && relayInit[3] == 0x02 { + continue + } + if relayInit[4] == 0 && relayInit[5] == 0 && relayInit[6] == 0 && relayInit[7] == 0 { + continue + } + break + } + + tgDecPrekeyAndIv := make([]byte, 48) + for i := 0; i < 48; i++ { + tgDecPrekeyAndIv[i] = relayInit[8+47-i] + } + + tgEncryptor, _ := newAESCTR(relayInit[8:40], relayInit[40:56]) + tgDecryptor, _ := newAESCTR(tgDecPrekeyAndIv[:32], tgDecPrekeyAndIv[32:]) + + dcBytes := make([]byte, 2) + dcIdx := dc + if isMedia { + dcIdx = -dc + } + binary.LittleEndian.PutUint16(dcBytes, uint16(dcIdx)) + + tailPlain := make([]byte, 8) + copy(tailPlain[0:4], protoTag) + copy(tailPlain[4:6], dcBytes) + rand.Read(tailPlain[6:8]) + + encryptedFull := make([]byte, 64) + tgEncryptor.XORKeyStream(encryptedFull, relayInit) + + keystreamTail := make([]byte, 8) + for i := 0; i < 8; i++ { + keystreamTail[i] = encryptedFull[56+i] ^ relayInit[56+i] + relayInit[56+i] = tailPlain[i] ^ keystreamTail[i] + } + + dcKey := [2]int{dc, isMediaInt(isMedia)} + now := float64(time.Now().UnixNano()) / 1e9 + + splitter, _ := newMsgSplitter(relayInit, proto) + + target, dcConfigured := resolveConfiguredTarget(dc, isMedia) + + wsBlackMu.RLock() + blacklisted := wsBlacklist[dcKey] + wsBlackMu.RUnlock() + + if !dcConfigured || blacklisted { + doFallback(ctx, clientConn, relayInit, label, dc, isMedia, splitter, cltDecryptor, cltEncryptor, tgEncryptor, tgDecryptor) + return + } + + dcFailMu.RLock() + failUntil := dcFailUntil[dcKey] + dcFailMu.RUnlock() + + wsTimeout := 10.0 + if now < failUntil { + wsTimeout = wsFailTimeout + } + + domains := wsDomains(dc, isMedia) + ws, wsFailedRedirect, allRedirects := connectDirectWS(ctx, target, domains, wsTimeout) + + if ws == nil { + logWarn.Printf(" DC%d%s: все попытки WS провалены (DPI/Интернет)", dc, mTag) + if wsFailedRedirect && allRedirects { + wsBlackMu.Lock() + wsBlacklist[dcKey] = true + wsBlackMu.Unlock() + logWarn.Printf(" DC%d%s заблокирован (302)", dc, mTag) + } else { + dcFailMu.Lock() + dcFailUntil[dcKey] = now + dcFailCooldown + dcFailMu.Unlock() + } + + splitterFb, _ := newMsgSplitter(relayInit, proto) + doFallback(ctx, clientConn, relayInit, label, dc, isMedia, splitterFb, cltDecryptor, cltEncryptor, tgEncryptor, tgDecryptor) + return + } + + sendDirectInit := func(activeWS *RawWebSocket) error { + if err := activeWS.Send(relayInit); err != nil { + return err + } + logDebug.Printf(" direct relayInit sent DC%d%s", dc, mTag) + return nil + } + + if err := sendDirectInit(ws); err != nil { + logWarn.Printf(" direct relayInit write fail DC%d%s: %s", dc, mTag, compactConnError(err)) + ws.Close() + + dcFailMu.Lock() + dcFailUntil[dcKey] = now + dcFailCooldown + dcFailMu.Unlock() + + logWarn.Printf(" direct retry fresh ws DC%d%s", dc, mTag) + retryWS, retryFailedRedirect, retryAllRedirects := connectDirectWS(ctx, target, domains, wsTimeout) + if retryWS == nil { + if retryFailedRedirect && retryAllRedirects { + wsBlackMu.Lock() + wsBlacklist[dcKey] = true + wsBlackMu.Unlock() + logWarn.Printf(" DC%d%s заблокирован (302)", dc, mTag) + } + logWarn.Printf(" direct fallback DC%d%s", dc, mTag) + splitterFb, _ := newMsgSplitter(relayInit, proto) + doFallback(ctx, clientConn, relayInit, label, dc, isMedia, splitterFb, cltDecryptor, cltEncryptor, tgEncryptor, tgDecryptor) + return + } + + ws = retryWS + if err = sendDirectInit(ws); err != nil { + logWarn.Printf(" direct relayInit write fail DC%d%s: %s", dc, mTag, compactConnError(err)) + ws.Close() + logWarn.Printf(" direct fallback DC%d%s", dc, mTag) + splitterFb, _ := newMsgSplitter(relayInit, proto) + doFallback(ctx, clientConn, relayInit, label, dc, isMedia, splitterFb, cltDecryptor, cltEncryptor, tgEncryptor, tgDecryptor) + return + } + } + + dcFailMu.Lock() + delete(dcFailUntil, dcKey) + dcFailMu.Unlock() + + stats.connectionsWs.Add(1) + + bridgeWS(ctx, clientConn, ws, label, dc, target, 443, isMedia, splitter, cltDecryptor, cltEncryptor, tgEncryptor, tgDecryptor) +} + +// --------------------------------------------------------------------------- +// Server +// --------------------------------------------------------------------------- + +func runProxy(ctx context.Context, host string, port int, dcOptMap map[int]string, started chan<- error) error { + dcOptMu.Lock() + dcOpt = dcOptMap + dcOptMu.Unlock() + + addr := net.JoinHostPort(host, strconv.Itoa(port)) + lc := net.ListenConfig{} + + listener, err := lc.Listen(ctx, "tcp", addr) + if err != nil { + signalProxyStart(started, fmt.Errorf("listen on %s: %w", addr, err)) + return fmt.Errorf("listen on %s: %w", addr, err) + } + signalProxyStart(started, nil) + + srvCtx, srvCancel := context.WithCancel(ctx) + defer srvCancel() + + startCfproxyRefresh(srvCtx) + + logInfo.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + logInfo.Println(" TG WS Proxy запущен") + logInfo.Printf(" Адрес: %s:%d", host, port) + + go func() { + ticker := time.NewTicker(60 * time.Second) + defer ticker.Stop() + for { + select { + case <-srvCtx.Done(): + return + case <-ticker.C: + logInfo.Printf(" %s", stats.SummaryRu()) + } + } + }() + + var activeConns sync.WaitGroup + + 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 + } + return + } + } + activeConns.Add(1) + go func() { + defer activeConns.Done() + handleClient(srvCtx, conn) + }() + } + }() + + <-srvCtx.Done() + _ = listener.Close() + + done := make(chan struct{}) + go func() { + activeConns.Wait() + close(done) + }() + + select { + case <-done: + case <-time.After(30 * time.Second): + } + + wsPool.CloseAll() + return nil +} + +func parseCIDRPool(cidrsStr string) (map[int]string, error) { + result := make(map[int]string) + if strings.TrimSpace(cidrsStr) == "" { + return result, nil + } + pairs := strings.Split(cidrsStr, ",") + for _, pair := range pairs { + parts := strings.Split(pair, ":") + if len(parts) == 2 { + dcRaw := strings.TrimSpace(parts[0]) + ipRaw := strings.TrimSpace(parts[1]) + if dc, err := strconv.Atoi(dcRaw); err == nil && ipRaw != "" { + if parsedIP := net.ParseIP(ipRaw); parsedIP != nil { + result[dc] = parsedIP.String() + } + } + } + } + return result, nil +} + +func signalProxyStart(started chan<- error, err error) { + if started == nil { + return + } + select { + case started <- err: + default: + } +} + +// --------------------------------------------------------------------------- +// CGO exports +// --------------------------------------------------------------------------- + +var ( + globalCtx context.Context + globalCancel context.CancelFunc + globalMu sync.Mutex +) + +//export StartProxy +func StartProxy(cHost *C.char, port C.int, cDcIps *C.char, cSecret *C.char, verbose C.int) C.int { + globalMu.Lock() + defer globalMu.Unlock() + + if globalCancel != nil { + return -1 + } + + host := C.GoString(cHost) + goPort := int(port) + dcIpsStr := C.GoString(cDcIps) + secretStr := C.GoString(cSecret) + isVerbose := int(verbose) != 0 + + initLogging(isVerbose) + clearCfproxy429Cooldowns() + + if len(secretStr) == 32 { + if _, err := hex.DecodeString(secretStr); err == nil { + proxySecretMu.Lock() + proxySecret = secretStr + proxySecretMu.Unlock() + } + } + + initCfproxyDomains() + + dcOptMap, err := parseCIDRPool(dcIpsStr) + if err != nil { + return -2 + } + + globalCtx, globalCancel = context.WithCancel(context.Background()) + started := make(chan error, 1) + + go func() { + _ = runProxy(globalCtx, host, goPort, dcOptMap, started) + }() + + if err := <-started; err != nil { + globalCancel() + globalCancel = nil + globalCtx = nil + return -3 + } + + return 0 +} + +//export StopProxy +func StopProxy() C.int { + globalMu.Lock() + defer globalMu.Unlock() + + if globalCancel == nil { + return -1 + } + + globalCancel() + globalCancel = nil + globalCtx = nil + + stats.Reset() + + wsBlackMu.Lock() + wsBlacklist = make(map[[2]int]bool) + wsBlackMu.Unlock() + + dcFailMu.Lock() + dcFailUntil = make(map[[2]int]float64) + dcFailMu.Unlock() + + clearCfproxy429Cooldowns() + + return 0 +} + +//export SetPoolSize +func SetPoolSize(size C.int) { + n := int32(size) + if n < 2 { + n = 2 + } + if n > 16 { + n = 16 + } + poolSize.Store(n) +} + +//export SetCfProxyCacheDir +func SetCfProxyCacheDir(cCacheDir *C.char) { + cfproxyMu.Lock() + cfproxyCacheDir = strings.TrimSpace(C.GoString(cCacheDir)) + cfproxyMu.Unlock() +} + +//export SetCfProxyConfig +func SetCfProxyConfig(enabled C.int, priority C.int, cUserDomain *C.char) { + cfproxyMu.Lock() + defer cfproxyMu.Unlock() + + cfproxyEnabled = int(enabled) != 0 + + userDomain := C.GoString(cUserDomain) + cfproxyUserDomain = userDomain + + if userDomain != "" { + cfproxyDomains = []string{userDomain} + activeCfDomain = userDomain + } +} + +//export SetSecret +func SetSecret(cSecret *C.char) { + s := C.GoString(cSecret) + if len(s) != 32 { + return + } + if _, err := hex.DecodeString(s); err != nil { + return + } + proxySecretMu.Lock() + proxySecret = s + proxySecretMu.Unlock() +} + +//export GetStats +func GetStats() *C.char { + return C.CString(stats.Summary()) +} + +//export SetFakeTls +func SetFakeTls(enabled C.int, cDomain *C.char) { + fakeTlsMu.Lock() + defer fakeTlsMu.Unlock() + + fakeTlsEnabled = int(enabled) != 0 + fakeTlsDomain = C.GoString(cDomain) +} + +//export GetSecretWithPrefix +func GetSecretWithPrefix() *C.char { + proxySecretMu.RLock() + sec := proxySecret + proxySecretMu.RUnlock() + + fakeTlsMu.RLock() + tlsOn := fakeTlsEnabled + tlsDom := fakeTlsDomain + fakeTlsMu.RUnlock() + + var result string + if tlsOn && tlsDom != "" { + domHex := hex.EncodeToString([]byte(tlsDom)) + result = "ee" + sec + domHex + } else { + result = "dd" + sec + } + return C.CString(result) +} + +//export FreeString +func FreeString(p *C.char) { + C.free(unsafe.Pointer(p)) +} + +func main() { + runtime.LockOSThread() + initLogging(true) + initCfproxyDomains() + + dcOptMap := map[int]string{ + 2: "149.154.167.220", + 4: "149.154.167.220", + } + + ctx, cancel := context.WithCancel(context.Background()) + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigCh + cancel() + }() + + _ = runProxy(ctx, "127.0.0.1", defaultPort, dcOptMap, nil) +} diff --git a/windows.py b/windows.py deleted file mode 100644 index 6eaad3f4..00000000 --- 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()