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..a1e54656 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) | [**Смотреть на Dzen**](https://dzen.ru/video/watch/69dcda2bd250b343c4de82ac) | [**Смотреть на 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..01ee7835 --- /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..9bae22d2 --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/AppUpdate.kt @@ -0,0 +1,334 @@ +package com.amurcanov.tgwsproxy + +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(hours: Int): String = when (hours) { + 7 -> "7 ч" + 24 -> "24 ч" + 48 -> "48 ч" + UPDATE_CHECK_NEVER -> "Никогда" + else -> "$hours ч" +} + +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..748c2399 --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/MainActivity.kt @@ -0,0 +1,760 @@ +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.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, "Не удалось запросить работу в фоне", 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 = remember { + listOf( + NavItem("Прокси", Icons.Default.PowerSettingsNew), + NavItem("Настройки", Icons.Default.Settings), + NavItem("Логи", Icons.Default.Terminal), + NavItem("Инфо", 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) "Не удалось проверить" 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, "Обновление отложено на 24 часа.", 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 = message.contains("Пул", ignoreCase = true) || + message.contains("Ключ:", ignoreCase = true) || + message.contains("запущен", ignoreCase = true) || + message.contains("Адрес:", ignoreCase = true) || + message.contains("ошибка", ignoreCase = true) || + message.contains("провалены", ignoreCase = true) || + message.contains("заблокирован", 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..8495d5d0 --- /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, "Неверный порт", 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..4fed5ad6 --- /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 = "Запуск прокси..." + 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("Ошибка запуска (код: $result)", force = true) + delay(3000) + stopProxy() + } + } + } catch (e: Throwable) { + Log.e(TAG, "Failed to start proxy via JNA", e) + serviceScope.launch { + updateNotification("Ошибка: ${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("Прокси работает", 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("⚠ Прокси не отвечает", 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 = "Трафик: ${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("Перезапуск прокси...", 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("Остановка прокси...", 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, + "Фоновый Прокси", + NotificationManager.IMPORTANCE_LOW + ).apply { + 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, "Перезапуск", restartPendingIntent) + .addAction(android.R.drawable.ic_menu_close_clear_cancel, "Отключить", 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..0800aa31 --- /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) "Подключен" else "Отключен" + } + 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..1ba7cdf1 --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/ui/AppUpdateDialog.kt @@ -0,0 +1,134 @@ +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.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) "Найден новый tag" else "Доступно обновление" + val description = if (isTagOnly) { + "На GitHub обнаружен более новый tag ${release.versionTag}. Похоже, опубликованный release ещё не догнал его." + } else { + "Вышла новая версия приложения ${release.versionTag}. Можно открыть страницу релиза и обновиться вручную." + } + val actionLabel = "Обновить" + + 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 = "Позже", + 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..d0ba5195 --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/ui/ConnectionTab.kt @@ -0,0 +1,349 @@ +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.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 -> "Подключение" + isRunning -> "Подключено" + else -> "Отключено" + } + + 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 = "Запуск", + 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( + "Применить в 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, "Скопировано", 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 = "Скопировать", + 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 "Прямое", + modifier = Modifier + .weight(0.9f) + .padding(horizontal = 6.dp, vertical = 8.dp) + ) + ProxyStatusDivider() + ProxyStatusItem( + text = "Пул x$poolSize", + modifier = Modifier + .weight(1.05f) + .padding(horizontal = 6.dp, vertical = 8.dp) + ) + ProxyStatusDivider() + ProxyStatusItem( + text = "Порт $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..01320dcb --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/ui/FloatingToolbar.kt @@ -0,0 +1,296 @@ +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.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 = "Тема", + 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( + "Тема", + 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 = "Системная", + selected = currentTheme == "system", + onClick = { onThemeChange("system"); isExpanded = false } + ) + ThemeOption( + icon = R.drawable.ic_light_mode, + label = "Светлая", + selected = currentTheme == "light", + onClick = { onThemeChange("light"); isExpanded = false } + ) + ThemeOption( + icon = R.drawable.ic_dark_mode, + label = "Тёмная", + 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( + "Динамические", + 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( + "Палитра", + 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..638095d2 --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/ui/InfoTab.kt @@ -0,0 +1,1257 @@ +package com.amurcanov.tgwsproxy.ui + +import android.content.ClipData +import android.content.ClipboardManager +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.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 = remember(isCheckingUpdates, updateLatestVersion, updateLastError, currentVersion) { + when { + isCheckingUpdates -> "Проверяем GitHub releases..." + updateLatestVersion.isNotBlank() && isNewerVersion(currentVersion, updateLatestVersion) -> + "На GitHub доступна версия $updateLatestVersion" + updateLatestVersion.isNotBlank() -> "Последняя версия: $updateLatestVersion" + updateLastError.isNotBlank() -> "Последняя проверка завершилась ошибкой" + else -> "Проверить GitHub вручную" + } + } + val reportText = remember( + savedPort, + savedPoolSize, + savedCfEnabled, + savedCustomCfDomainEnabled, + savedCustomCfDomain, + currentLogs + ) { + buildSupportReport( + port = savedPort, + poolSize = savedPoolSize, + cfEnabled = savedCfEnabled, + customCfDomainEnabled = savedCustomCfDomainEnabled, + customCfDomain = savedCustomCfDomain, + logs = currentLogs + ) + } + + 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 = "Информация", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface + ) + } + + InfoHeroCard(onSupportClick = { showDonateDialog = true }) + + ExpandableSectionCard( + title = "Действия", + itemCount = "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 = "Поднять вопрос", + subtitle = "Открыть 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 = "Собрать отчёт", + subtitle = "Android, ABI, настройки, ошибки", + modifier = Modifier.weight(1f), + onClick = { + val clipboard = context.getSystemService(ClipboardManager::class.java) + clipboard?.setPrimaryClip(ClipData.newPlainText("TgWsProxy Report", reportText)) + Toast.makeText(context, "Отчёт сформирован и скопирован", Toast.LENGTH_SHORT).show() + }, + icon = { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + ) + } + + WideActionTile( + title = "Справка", + subtitle = "Коротко про Cloudflare, пул WS, ручные DC и долгий запуск", + onClick = { showHelpDialog = true }, + icon = { + Icon( + imageVector = Icons.AutoMirrored.Filled.HelpOutline, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + ) + + WideActionTile( + title = "Проверить обновления", + 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) "Не удалось проверить" else "" + ) + isCheckingUpdates = false + + if (release == null) { + val message = if (updateLatestVersion.isNotBlank()) { + "Не удалось проверить. Последняя известная версия: $updateLatestVersion" + } else { + "Не удалось проверить обновления" + } + 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, + "У вас уже последняя версия: ${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 = "О проекте", + itemCount = "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 = "Автор Android-версии", + subtitle = "GitHub профиль amurcanov", + onClick = { openUrlInBrowser(context, DeveloperProfileUrl) }, + icon = { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + } + ) + + ProjectLinkRow( + title = "Репозиторий Android-форка", + 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 = "Оригинальный tg-ws-proxy", + subtitle = "Исходная идея и upstream от Flowseal", + onClick = { openUrlInBrowser(context, OriginalProjectUrl) }, + icon = { + Icon( + imageVector = Icons.Default.Code, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + } + ) + + ProjectLinkRow( + title = "Полезный материал", + subtitle = "Заметки по работе прокси от IMDelewer", + 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, "Обновление отложено на 24 часа.", 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( + "Справка", + 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 = "Авто / Адреса датацентров", + text = "При включенном CloudFlare ручные адреса DC обычно не нужны: прокси использует CF-маршрут. Если CloudFlare выключен, соединение идёт напрямую на Telegram DC, и тогда можно задать адреса вручную. В обычном режиме чаще всего достаточно DC2 и DC4; остальные адреса нужны в основном для ручной настройки и диагностики." + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + HelpSection( + title = "CloudFlare CDN", + text = "Этот режим направляет соединение через WebSocket-домены за Cloudflare. На части мобильных сетей он работает стабильнее, но итог зависит от маршрута, DNS и конкретного провайдера. Если на вашей сети подключение стало дольше или менее стабильным, имеет смысл сравнить работу с выключенным CF." + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + HelpSection( + title = "Пул WS", + text = "Количество заранее подготовленных WebSocket-соединений. Больший пул может уменьшить задержку при первом подключении и загрузке медиа, но увеличивает число фоновых соединений. Для большинства сценариев достаточно 2-4; повышать значение стоит только если реально видна польза на вашей сети." + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + HelpSection( + title = "Секретный ключ", + text = "Специальный 16-байтовый ключ шифрования MTProto. Меняйте его только в случае, если старой ссылкой для подключения завладели посторонние." + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + HelpSection( + title = "Экспериментальный режим", + text = "Открывает ручную настройку всех обычных и media-датацентров: DC1, DC3, DC5, DC203 и их media-вариантов. Он нужен для диагностики, тестов и нестандартных маршрутов. Если у вас нет явной задачи под ручную маршрутизацию, лучше держать этот режим выключенным." + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + HelpSection( + title = "Автозапуск", + text = "Включает попытку поднять прокси автоматически после перезагрузки устройства. Это удобно, если вы используете локальный прокси постоянно. На некоторых прошивках запуск может произойти не мгновенно: система иногда завершает его только после полной загрузки Android или первого разблокирования." + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + HelpSection( + title = "Если долго подключается", + text = "Если после запуска прокси Telegram долго висит на подключении, это обычно означает неудачный текущий маршрут, а не падение приложения. В такой ситуации полезно быстро перезапустить прокси и сравнить поведение с включенным и выключенным CloudFlare." + ) + } + + Spacer(Modifier.height(8.dp)) + + Button( + onClick = { showHelpDialog = false }, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + shape = RoundedCornerShape(24.dp) + ) { + Text("Понятно", 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 = "Локальный MTProto-прокси для Android с прямым маршрутом и Cloudflare-режимом. Удобен для сетей, где Telegram грузится нестабильно или упирается в маршрут.", + 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 = "Поддержать развитие", + 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 = "Поддержка проекта", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = colors.onSurface + ) + } + } + + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = "Если приложение тебе реально помогает, проект можно поддержать.", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Black, + color = colors.onSurface + ) + Text( + text = "Внутри есть варианты доната для автора Android-версии и для автора оригинальной идеи.", + 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 = "Открыть варианты доната", + fontWeight = FontWeight.Bold, + fontSize = 15.sp + ) + } + } + } + } +} + +private fun buildSupportReport( + port: String, + poolSize: Int, + cfEnabled: Boolean, + customCfDomainEnabled: Boolean, + customCfDomain: String, + logs: List +): 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 "Прямой" + val cfDomainLine = if (cfEnabled && customCfDomainEnabled && customCfDomain.isNotBlank()) { + "\nCF-домен: ${customCfDomain.trim()}" + } else { + "" + } + + val recentErrors = logs + .asReversed() + .filter { it.priority >= 5 } + .take(10) + + val errorsBlock = if (recentErrors.isEmpty()) { + "нет" + } 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("Версия приложения: ${BuildConfig.VERSION_NAME}") + appendLine("Андроид: $androidVersion (SDK $sdkInt)") + appendLine("Устройство: $manufacturer / $brand / $model") + appendLine("Код устройства: $device") + appendLine("Продукт: $product") + appendLine("ABI: $primaryAbi") + appendLine("Все 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("Настройки:") + appendLine("Режим: $mode") + appendLine("WS-пул: $poolSize") + append("Порт: ${port.trim().ifBlank { "1443" }}") + append(cfDomainLine) + appendLine() + appendLine() + appendLine("Последние ошибки:") + 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 = "Донат разработчикам", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Black, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + FilledTonalIconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Закрыть" + ) + } + } + + DonateSection( + title = "Задонатить автору данного андроид приложения вы можете тут", + 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 = "Задонатить автору оригинальной идеи вы можете тут", + 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..996500f2 --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/ui/LogsTab.kt @@ -0,0 +1,242 @@ +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.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 filteredLogs = remember(currentLogs, savedInfo, savedError, savedNull) { + if (savedNull) { + listOf(LogEntry( + key = "null_msg", + message = "NULL - логи отключены", + 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( + "Лог событий", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface + ) + Row { + IconButton(onClick = { LogManager.clearLogs() }) { + Icon(Icons.Default.Delete, contentDescription = "Очистить", 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, "Скопировано", Toast.LENGTH_SHORT).show() + }) { + Icon(Icons.Default.ContentCopy, contentDescription = "Копировать", 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..264177c5 --- /dev/null +++ b/app/src/main/java/com/amurcanov/tgwsproxy/ui/SettingsTab.kt @@ -0,0 +1,585 @@ +package com.amurcanov.tgwsproxy.ui + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +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.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 + +val telegramApps = listOf( + "org.telegram.messenger", + "org.thunderdog.challegram", + "com.radolyn.ayugram", + "app.exteragram.messenger", + "ir.ilmili.telegraph", + "org.telegram.plus", + "tw.nekomimi.nekogram", + "tw.nekomimi.nekogramx", + "org.telegram.mdgram", + "com.iMe.android", + "app.nicegram", + "org.telegram.bgram", + "cc.modery.cherrygram", + "io.github.nextalone.nagram" +) + +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) { + val pm = context.packageManager + val uri = Uri.parse(url) + for (pkg in telegramApps) { + try { + pm.getPackageInfo(pkg, 0) + val intent = Intent(Intent.ACTION_VIEW, uri) + intent.setPackage(pkg) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + return + } catch (_: PackageManager.NameNotFoundException) { + } catch (_: Exception) { + } + } + try { + val fallbackIntent = Intent(Intent.ACTION_VIEW, uri) + fallbackIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(fallbackIntent) + } catch (_: Exception) { + Toast.makeText(context, "Telegram не найден!", 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 = "Настройки", + 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( + "Подключение", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold + ) + } + OutlinedTextField( + value = portText, + onValueChange = { portText = it; scheduleSave() }, + label = { Text("Порт") }, + 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("Авто (Включён CF)", fontWeight = FontWeight.SemiBold) + } else { + Spacer(Modifier.width(8.dp)) + Text("Настроить адреса DC", 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( + "Пул WS", + 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( + "Секретный ключ", + 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( + "Автозапуск", + 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( + "Адреса датацентров", + 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("Медиа датацентры", 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( + "Экспериментальный режим", + 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("Готово", 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/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/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()