├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── custom.md │ └── feature_request.md ├── .gitignore ├── LICENSE ├── README.md ├── config ├── docs ├── README.zh-tw.md ├── README.zh.md ├── assets │ ├── clip.png │ ├── cmd.png │ ├── compare.png │ ├── file.png │ ├── say.png │ ├── setbase.png │ ├── speedtest.png │ ├── startup.png │ └── sysinfo.png └── build_guide │ ├── FTT.png │ ├── build.py │ ├── readme.txt │ └── upx.exe ├── requirements.txt └── src ├── constants.py ├── ftc.py ├── fts.py ├── ftt.py ├── ftt_base.py ├── ftt_lib.py ├── ftt_sn.py ├── pbar_manager.py ├── sys_info.py ├── test ├── test_ftc.py ├── test_fts.py └── tools.py ├── utils.py └── win_set_time.py /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Describe this issue template's purpose here. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | auto commit.bat 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | FTT 162 | FTT.zip 163 | test.* -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # File Transfer Tool 2 | [简体中文](./docs/README.zh.md) | [繁体中文](./docs/README.zh-tw.md) 3 | ## Introduction 4 | 5 | `File Transfer Tool` is a **lightweight**, **fast**, **safe**, and **multifunctional** cross-device file transfer tool. 6 | 7 | ### Function 8 | 9 | 1. File transfer 10 | 11 | - Can transfer a single file or an entire folder, and supports resumed transfers 12 | - Security guarantee: Generate an exclusive TLS/SSL security certificate for each session to maximize security 13 | - Progress bar display: Real-time display of file transfer progress, current network speed, remaining transfer time and other information 14 | - Specially optimized for transfer of small files (<1MB) 15 | 16 | 2. Provides a simple ssh-like function that can execute commands remotely and return results in real time 17 | 3. Automatically search for the service host, or manually specify the connection host 18 | 4. Folder comparison can display information such as similarities and differences between files in two folders. 19 | 5. Check system status and information of both parties 20 | 6. Output logs to the console and files in real time, and automatically organize and compress log files 21 | 7. Test the network bandwidth between both parties 22 | 8. Information can be transmitted at both ends to implement simple chat functions 23 | 9. Synchronize the clipboard contents at both ends 24 | 10. You can set a connection password for the server to enhance security 25 | 26 | ### Features 27 | 28 | 1. Fast startup, operation and response speed 29 | 2. Adopt the minimum default configuration principle, which can be used out of the box, or you can easily modify the configuration yourself. 30 | 2. It can be used in any network environment such as local area network or public network, as long as the two hosts can be connected to the network. 31 | 3. You can specify the number of threads and use multi-thread transmission 32 | 4. Receive information such as modification time and access time of retained files and folders 33 | 5. It can be turned on and off immediately, and no process will remain after closing the program. 34 | 6. Currently adapted to Windows and Linux platforms 35 | 36 | ### How to choose 37 | 38 | 1. If you want a more powerful file transfer service, please choose an FTP server or client (such as `FileZilla`, `WinSCP`, etc.) 39 | 2. If you want stable file synchronization and sharing, it is recommended to use `Resilio Sync`, `Syncthing`, etc. 40 | 3. If you only transfer files occasionally/don’t like the background storage and resource usage of the above services/don’t need such powerful services/want to customize the functions yourself, please choose `File Transfer Tools` 41 | 42 | ## Install and run 43 | 44 | ### Method 1: Download the executable program 45 | 46 | 1. Click `Release` on the right 47 | 2. Download the compressed package 48 | 3. Unzip the folder and double-click `FTT.exe` to run it with the default configuration. 49 | 4. Or run the program in the terminal to use program parameters, such as `.\FTT.exe [-h] [-t thread] [-host host] [-d destination] [-p password] ` 50 | 51 | ### Method 2: Run using Python interpreter 52 | 53 | 1. Clone the source code to your project location 54 | 2. Use `pip install -r requirements.txt` to install all dependencies 55 | 3. Execute the script using your python interpreter 56 | 57 | ## Usage 58 | 59 | FTT can provide services to two parties at the same time, and both parties can transfer files to each other and execute instructions. 60 | 61 | ### Things to note when establishing a connection 62 | 1. If no password is set, FTT will automatically search for the host and connect to it by default after opening it. It is recommended to use this method only in a simple LAN environment. 63 | 2. If you are in a complex network environment or need to connect to the public network, one party needs to set a password, and the other party needs to specify the host name or IP address and password to connect. 64 | 65 | #### Parameter Description 66 | 67 | ``` 68 | usage: FTT.py [-h] [-t thread] [-host host] [-p password] [-d base_dir] 69 | 70 | File Transfer Tool, used to transfer files and execute commands. 71 | 72 | options: 73 | -h, --help show this help message and exit 74 | -t thread Threads (default: cpu count) 75 | -host host Destination hostname or ip address 76 | -p password, --password password 77 | Set a password for the host or Use a password to connect host. 78 | -d base_dir, --dest base_dir 79 | File save location (default: ~\Desktop) 80 | ``` 81 | 82 | `-t`: Specify the number of threads, the default is the number of processors. 83 | 84 | `-p`: Explicitly set the host password or specify the connection password (no password by default). When this option is not used, servers under the same subnet are automatically searched. 85 | 86 | `-host`: Specify the host name of the other party (hostname or ip can be used) and port number (optional), which must be used with `-p`. 87 | 88 | `-d`: Explicitly specify the file receiving location, the default is **desktop** on Windows platform. 89 | 90 | 91 | 92 | #### Command description 93 | 94 | After the connection is successful, enter the command 95 | 96 | 1. Enter the file (folder) path, and the file (folder) will be sent. 97 | 2. Enter `sysinfo`, the system information of both parties will be displayed. 98 | 3. Enter `speedtest n`, and the network speed will be tested, where n is the amount of data for this test, in MB. Note that in **Computer Network**, 1 GB = 1000 MB = 1000000 KB. 99 | 4. Enter `compare local_dir dest_dir` to compare the differences in files in the local folder and the server folder. 100 | 5. Enter `say` to send a message to the other party, which can be used as a simple chat server 101 | 6. Enter `setbase` to change the file receiving location 102 | 7. Enter `get clipboard` or `send clipboard` to synchronize the clipboard contents of the client and server 103 | 8. When inputting other content, it will be used as a command to be executed by the server, and the results will be returned in real time. 104 | 105 | #### Running screenshot 106 | 107 | The following are screenshots running on the same host. 108 | 109 | Program start 110 | 111 | ![startup](docs/assets/startup.png) 112 | 113 | Transfer files 114 | 115 | ![file](docs/assets/file.png) 116 | 117 | Execute command: sysinfo 118 | 119 | ![sysinfo](docs/assets/sysinfo.png) 120 | 121 | Execute command: speedtest 122 | 123 | ![speedtest](docs/assets/speedtest.png) 124 | 125 | Execute command: compare 126 | 127 | ![compare](docs/assets/compare.png) 128 | 129 | Execute command: clip 130 | 131 | ![clip](docs/assets/clip.png) 132 | 133 | Execute command: say 134 | 135 | ![say](docs/assets/say.png) 136 | 137 | Execute command: setbase 138 | 139 | ![setbase](docs/assets/setbase.png) 140 | 141 | Execute command line command 142 | 143 | ![command](docs/assets/cmd.png) 144 | 145 | 146 | ## Configuration 147 | 148 | The configuration items are in the configuration file `config`. When the configuration file does not exist, the program will use the default configuration. 149 | 150 | ### Main program main configuration 151 | 152 | `windows_default_path`: The default file receiving location under Windows platform 153 | 154 | `linux_default_path`: The default file receiving location under Linux platform 155 | 156 | ### Log log related configuration 157 | 158 | `windows_log_dir`: The default log file storage location under Windows platform 159 | 160 | `linux_log_dir`: The default log file storage location under the Linux platform 161 | 162 | `log_file_archive_count`: archive when the number of log files exceeds this size 163 | 164 | `log_file_archive_size`: Archive when the total size (bytes) of the log file exceeds this size 165 | 166 | ### Port configuration port related content 167 | 168 | `server_port`: Server TCP listening port 169 | 170 | `signal_port`: UDP listening port -------------------------------------------------------------------------------- /config: -------------------------------------------------------------------------------- 1 | [Main] 2 | # The default receive location for files 3 | windows_default_path = ~/Desktop 4 | linux_default_path = ~/FileTransferTool/FileRecv 5 | 6 | [Log] 7 | windows_log_dir = C:/ProgramData/logs 8 | linux_log_dir = ~/FileTransferTool/logs 9 | # Number of log files archived 10 | log_file_archive_count = 10 11 | # Size of log files archived 12 | log_file_archive_size = 52428800 13 | 14 | [Port] 15 | server_port = 2023 16 | signal_port = 2022 17 | -------------------------------------------------------------------------------- /docs/README.zh-tw.md: -------------------------------------------------------------------------------- 1 | # 檔案傳輸小工具 2 | 3 | ## 簡介 4 | 5 | `File Transfer Tool` ,是**輕量**、**快速**、**安全**、**多功能**的跨裝置檔案傳輸小工具。 6 | 7 | ### 功能 8 | 9 | 1. 文件傳輸 10 | 11 | - 可傳輸單一檔案或整個資料夾,支援斷點續傳 12 | - 安全性保障:為每次會話產生專屬的TLS/SSL安全證書,並最大限度地保障安全 13 | - 進度條顯示:即時顯示檔案傳輸進度、目前網路速率、剩餘傳輸時長等訊息 14 | - 對小檔案 (<1MB) 的傳輸進行了特別優化 15 | 16 | 2. 提供簡易的類似ssh的功能,可在遠端執行命令並即時返回結果 17 | 3. 自動尋找服務主機,也可手動指定連接主機 18 | 4. 資料夾比較,可顯示兩個資料夾中的檔案的相同、差異等訊息 19 | 5. 查看雙方系統狀態、訊息 20 | 6. 即時輸出日誌到控制台和文件中,並且可以自動整理壓縮日誌文件 21 | 7. 測試雙方之間的網路頻寬 22 | 8. 可以兩端傳送訊息,實現簡單的聊天功能 23 | 9. 同步兩端的剪切板內容 24 | 10. 可以為伺服器設定連線密碼,增強安全性 25 | 26 | ### 特點 27 | 28 | 1. 啟動、運轉、反應速度快 29 | 2. 採用最小預設配置原則,即開即用,也可以方便地自行修改配置 30 | 2. 可在區域網路、公網等任一網路環境使用,只要兩台主機可以進行網路連線即可 31 | 3. 可以指定執行緒數,採用多執行緒傳輸 32 | 4. 對於接收保留檔案、資料夾的修改時間、存取時間等訊息 33 | 5. 即用即開,即關即走,關閉程式後不會殘留進程 34 | 6. 目前適配Windows和Linux平台 35 | 36 | ### 如何選擇 37 | 38 | 1. 如果你想要功能更強大的檔案傳輸服務,請選擇FTP伺服器、客戶端(如`FileZilla`、`WinSCP`等) 39 | 2. 如果你想要穩定的檔案同步與分享,推薦使用`Resilio Sync`、`Syncthing`等 40 | 3. 如果你只是偶爾傳輸檔案/不喜歡上述服務的後台存留、資源佔用/不需要那麼強大的服務/想要自己定制功能那請選擇`File Transfer Tools` 41 | 42 | ## 安裝與運行 43 | 44 | ### 方法一:下載可執行程式 45 | 46 | 1. 點選右側`Release` 47 | 2. 下載壓縮包 48 | 3. 解壓縮資料夾,雙擊`FTT.exe`即可以預設配置運行 49 | 4. 或在終端機中執行程式以使用程式參數,例如`.\FTT.exe [-h] [-t thread] [-host host] [-d destination] [-p password] ` 50 | 51 | ### 方法二:使用Python解釋器運行 52 | 53 | 1. 將原始碼複製到你的專案位置 54 | 2. 使用`pip install -r requirements.txt`安裝所有依賴項 55 | 3. 使用你的python解釋器執行腳本 56 | 57 | ## 用法 58 | 59 | FTT 可同時為兩方提供服務,雙方都可以互相傳輸文件,執行指令。 60 | 61 | ### 建立連線時需要注意的事項 62 | 1. 若未設定密碼,FTT 開啟後預設自動尋找主機並連接,建議僅在簡單區域網路環境下使用該方式。 63 | 2. 若在複雜網路環境下或需要連接公網,一方需設定密碼,另一方指定主機名稱或ip位址及密碼進行連線。 64 | 65 | #### 參數說明 66 | 67 | ``` 68 | usage: FTT.py [-h] [-t thread] [-host host] [-p password] [-d base_dir] 69 | 70 | File Transfer Tool, used to transfer files and execute commands. 71 | 72 | options: 73 | -h, --help show this help message and exit 74 | -t thread Threads (default: cpu count) 75 | -host host Destination hostname or ip address 76 | -p password, --password password 77 | Set a password for the host or Use a password to connect host. 78 | -d base_dir, --dest base_dir 79 | File save location (default: ~\Desktop) 80 | ``` 81 | 82 | `-t`: 指定執行緒數,預設為處理器數量。 83 | 84 | `-p`: 明確設定主機密碼或指定連線密碼(預設沒有密碼),不使用此選項時,自動尋找**同一子網路**下的伺服器。 85 | 86 | `-host`: 指定對方的主機名稱(可使用hostname或ip)及連接埠號碼(可選),需搭配`-p`使用。 87 | 88 | `-d`: 明確指定檔案接收位置,Windows平台預設為**桌面**。 89 | 90 | 91 | 92 | #### 指令說明 93 | 94 | 連線成功後,輸入指令 95 | 96 | 1. 輸入檔案(夾)路徑,則會傳送檔案(夾) 97 | 2. 輸入`sysinfo`,則會顯示雙方的系統訊息 98 | 3. 輸入`speedtest n`,則會測試網速,其中n為本次測試的資料量,單位MB。 注意,在**電腦網路**中,1 GB = 1000 MB = 1000000 KB. 99 | 4. 輸入`compare local_dir dest_dir`來比較本機資料夾和伺服器資料夾中檔案的差異。 100 | 5. 輸入`say`給對方發送訊息,可以作為簡單聊天伺服器使用 101 | 6. 輸入`setbase`來改變檔案接收位置 102 | 7. 輸入`get clipboard` 或 `send clipboard`來同步客戶端和伺服器的剪切板內容 103 | 8. 輸入其他內容時作為指令讓伺服器執行,並且即時回傳結果。 104 | 105 | #### 運行截圖 106 | 107 | 以下均為在同一台主機上執行的截圖。 108 | 109 | 程式啟動 110 | 111 | ![startup](assets/startup.png) 112 | 113 | 傳輸檔案 114 | 115 | ![file](assets/file.png) 116 | 117 | 執行指令:sysinfo 118 | 119 | ![sysinfo](assets/sysinfo.png) 120 | 121 | 執行指令:speedtest 122 | 123 | ![speedtest](assets/speedtest.png) 124 | 125 | 執行命令:compare 126 | 127 | ![compare](assets/compare.png) 128 | 129 | 執行命令:clip 130 | 131 | ![clip](assets/clip.png) 132 | 133 | 執行命令:say 134 | 135 | ![say](assets/say.png) 136 | 137 | 執行指令:setbase 138 | 139 | ![setbase](assets/setbase.png) 140 | 141 | 執行命令列命令 142 | 143 | ![command](assets/cmd.png) 144 | 145 | 146 | ## 配置 147 | 148 | 配置項在設定檔`config`中,當設定檔不存在時,程式會使用預設配置 149 | 150 | ### Main 程式的主要配置 151 | 152 | `windows_default_path`: Windows平台下預設的檔案接收位置 153 | 154 | `linux_default_path`: Linux平台下預設的檔案接收位置 155 | 156 | ### Log 日誌相關配置 157 | 158 | `windows_log_dir`: Windows平台下預設的日誌檔案存放位置 159 | 160 | `linux_log_dir`: Linux平台下預設的日誌檔案存放位置 161 | 162 | `log_file_archive_count`: 當日誌檔案數超過該大小時歸檔 163 | 164 | `log_file_archive_size`: 當日誌檔案的總大小(位元組)超過該大小時歸檔 165 | 166 | ### Port 設定連接埠相關內容 167 | 168 | `server_port`:伺服器 TCP 偵聽連接埠 169 | 170 | `signal_port`:UDP 偵聽連接埠 -------------------------------------------------------------------------------- /docs/README.zh.md: -------------------------------------------------------------------------------- 1 | # 文件传输小工具 2 | 3 | ## 简介 4 | 5 | `File Transfer Tool` ,是**轻量**、**快速**、**安全**、**多功能**的跨设备文件传输小工具。 6 | 7 | ### 功能 8 | 9 | 1. 文件传输 10 | 11 | - 可传输单个文件或者整个文件夹,支持断点续传 12 | - 安全性保障:为每次会话生成专属的TLS/SSL安全证书,最大限度保障安全 13 | - 进度条显示:实时显示文件传输进度、当前网络速率、剩余传输时长等信息 14 | - 对小文件 (<1MB) 的传输进行了特别优化 15 | 16 | 2. 提供简易的类似ssh的功能,可在远端执行命令并实时返回结果 17 | 3. 自动寻找服务主机,也可手动指定连接主机 18 | 4. 文件夹比较,可显示两个文件夹中的文件的相同、差异等信息 19 | 5. 查看双方系统状态、信息 20 | 6. 实时输出日志到控制台和文件中,并且可以自动整理压缩日志文件 21 | 7. 测试双方之间的网络带宽 22 | 8. 可以在两端传输信息,实现简单的聊天功能 23 | 9. 同步两端的剪切板内容 24 | 10. 可以为服务器设置连接密码,增强安全性 25 | 26 | ### 特点 27 | 28 | 1. 启动、运行、响应速度快 29 | 2. 采用最小默认配置原则,即开即用,也可以方便地自己修改配置 30 | 2. 可在局域网、公网等任意网络环境下使用,只要两台主机可以进行网络连接即可 31 | 3. 可以指定线程数,采用多线程传输 32 | 4. 对于接收保留文件、文件夹的修改时间、访问时间等信息 33 | 5. 即用即开,即关即走,关闭程序后不会残留进程 34 | 6. 目前适配Windows和Linux平台 35 | 36 | ### 如何选择 37 | 38 | 1. 如果你想要功能更强大的文件传输服务,请选择FTP服务器、客户端(如`FileZilla`、`WinSCP`等) 39 | 2. 如果你想要稳定的文件同步和共享,推荐使用`Resilio Sync`、`Syncthing`等 40 | 3. 如果你只是偶尔传输文件/不喜欢上述服务的后台存留、资源占用/不需要那么强大的服务/想要自己定制功能那请选择`File Transfer Tools` 41 | 42 | ## 安装与运行 43 | 44 | ### 方法一:下载可执行程序 45 | 46 | 1. 点击右侧`Release` 47 | 2. 下载压缩包 48 | 3. 解压文件夹,双击`FTT.exe`即可以默认配置运行 49 | 4. 或者在终端中运行程序以使用程序参数,例如`.\FTT.exe [-h] [-t thread] [-host host] [-d destination] [-p password] ` 50 | 51 | ### 方法二:使用Python解释器运行 52 | 53 | 1. 将源代码克隆到你的项目位置 54 | 2. 使用`pip install -r requirements.txt`安装所有依赖项 55 | 3. 使用你的python解释器执行脚本 56 | 57 | ## 用法 58 | 59 | FTT 可同时为两方提供服务,双方都可以互相传输文件,执行指令。 60 | 61 | ### 建立连接时需要注意的事项 62 | 1. 若未设置密码,FTT 打开后默认自动寻找主机并连接,建议仅在简单局域网环境下使用该方式。 63 | 2. 若在复杂网络环境下或者需要连接到公网,一方需设置密码,另一方指定主机名或ip地址和密码进行连接。 64 | 65 | #### 参数说明 66 | 67 | ``` 68 | usage: FTT.py [-h] [-t thread] [-host host] [-p password] [-d base_dir] 69 | 70 | File Transfer Tool, used to transfer files and execute commands. 71 | 72 | options: 73 | -h, --help show this help message and exit 74 | -t thread Threads (default: cpu count) 75 | -host host Destination hostname or ip address 76 | -p password, --password password 77 | Set a password for the host or Use a password to connect host. 78 | -d base_dir, --dest base_dir 79 | File save location (default: ~\Desktop) 80 | ``` 81 | 82 | `-t`: 指定线程数,默认为处理器数量。 83 | 84 | `-p`: 显式设置主机密码或指定连接密码(默认情况下没有密码),不使用此选项时,自动寻找**同一子网**下的服务器。 85 | 86 | `-host`: 指定对方的主机名(可使用hostname或ip)及端口号(可选),需搭配`-p`使用。 87 | 88 | `-d`: 显式指定文件接收位置,Windows平台默认为**桌面**。 89 | 90 | 91 | 92 | #### 命令说明 93 | 94 | 连接成功后,输入指令 95 | 96 | 1. 输入文件(夹)路径,则会发送文件(夹) 97 | 2. 输入`sysinfo`,则会显示双方的系统信息 98 | 3. 输入`speedtest n`,则会测试网速,其中n为本次测试的数据量,单位MB。注意,在**计算机网络**中,1 GB = 1000 MB = 1000000 KB. 99 | 4. 输入`compare local_dir dest_dir`来比较本机文件夹和服务器文件夹中文件的差别。 100 | 5. 输入`say`给对方发送信息,可以作为简单聊天服务器使用 101 | 6. 输入`setbase`来改变文件接收位置 102 | 7. 输入`get clipboard` 或 `send clipboard`来同步客户端和服务器的剪切板内容 103 | 8. 输入其他内容时作为指令让服务器执行,并且实时返回结果。 104 | 105 | #### 运行截图 106 | 107 | 以下均为运行在同一台主机上的截图。 108 | 109 | 程序启动 110 | 111 | ![startup](assets/startup.png) 112 | 113 | 传输文件 114 | 115 | ![file](assets/file.png) 116 | 117 | 执行命令:sysinfo 118 | 119 | ![sysinfo](assets/sysinfo.png) 120 | 121 | 执行命令:speedtest 122 | 123 | ![speedtest](assets/speedtest.png) 124 | 125 | 执行命令:compare 126 | 127 | ![compare](assets/compare.png) 128 | 129 | 执行命令:clip 130 | 131 | ![clip](assets/clip.png) 132 | 133 | 执行命令:say 134 | 135 | ![say](assets/say.png) 136 | 137 | 执行命令:setbase 138 | 139 | ![setbase](assets/setbase.png) 140 | 141 | 执行命令行命令 142 | 143 | ![command](assets/cmd.png) 144 | 145 | 146 | ## 配置 147 | 148 | 配置项在配置文件`config`中,当配置文件不存在时,程序会使用默认配置 149 | 150 | ### Main 程序的主要配置 151 | 152 | `windows_default_path`: Windows平台下默认的文件接收位置 153 | 154 | `linux_default_path`: Linux平台下默认的文件接收位置 155 | 156 | ### Log 日志相关配置 157 | 158 | `windows_log_dir`: Windows平台下默认的日志文件存放位置 159 | 160 | `linux_log_dir`: Linux平台下默认的日志文件存放位置 161 | 162 | `log_file_archive_count`: 当日志文件数超过该大小时归档 163 | 164 | `log_file_archive_size`: 当日志文件的总大小(字节)超过该大小时归档 165 | 166 | ### Port 配置端口相关内容 167 | 168 | `server_port`:服务器 TCP 侦听端口 169 | 170 | `signal_port`:UDP 侦听端口 171 | 172 | -------------------------------------------------------------------------------- /docs/assets/clip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CrazyMayfly/File-Transfer-Tools/b434acb5323ca437662f2a2297366ffb0bcae06e/docs/assets/clip.png -------------------------------------------------------------------------------- /docs/assets/cmd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CrazyMayfly/File-Transfer-Tools/b434acb5323ca437662f2a2297366ffb0bcae06e/docs/assets/cmd.png -------------------------------------------------------------------------------- /docs/assets/compare.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CrazyMayfly/File-Transfer-Tools/b434acb5323ca437662f2a2297366ffb0bcae06e/docs/assets/compare.png -------------------------------------------------------------------------------- /docs/assets/file.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CrazyMayfly/File-Transfer-Tools/b434acb5323ca437662f2a2297366ffb0bcae06e/docs/assets/file.png -------------------------------------------------------------------------------- /docs/assets/say.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CrazyMayfly/File-Transfer-Tools/b434acb5323ca437662f2a2297366ffb0bcae06e/docs/assets/say.png -------------------------------------------------------------------------------- /docs/assets/setbase.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CrazyMayfly/File-Transfer-Tools/b434acb5323ca437662f2a2297366ffb0bcae06e/docs/assets/setbase.png -------------------------------------------------------------------------------- /docs/assets/speedtest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CrazyMayfly/File-Transfer-Tools/b434acb5323ca437662f2a2297366ffb0bcae06e/docs/assets/speedtest.png -------------------------------------------------------------------------------- /docs/assets/startup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CrazyMayfly/File-Transfer-Tools/b434acb5323ca437662f2a2297366ffb0bcae06e/docs/assets/startup.png -------------------------------------------------------------------------------- /docs/assets/sysinfo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CrazyMayfly/File-Transfer-Tools/b434acb5323ca437662f2a2297366ffb0bcae06e/docs/assets/sysinfo.png -------------------------------------------------------------------------------- /docs/build_guide/FTT.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CrazyMayfly/File-Transfer-Tools/b434acb5323ca437662f2a2297366ffb0bcae06e/docs/build_guide/FTT.png -------------------------------------------------------------------------------- /docs/build_guide/build.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import subprocess 4 | from pathlib import Path, PurePath 5 | import platform 6 | 7 | parent_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 8 | resource_dir = os.path.normcase(os.path.join(parent_dir, 'docs/build_guide')) 9 | src_dir = os.path.normcase(os.path.join(parent_dir, 'src')) 10 | 11 | 12 | class Build: 13 | def __init__(self, folder=False, target_dir_name='FTT', version=''): 14 | self.__bundle_type = '--onedir' if folder else '--onefile' 15 | self.__target_dir_name = target_dir_name 16 | self.__output_dir: Path = Path(parent_dir, target_dir_name) 17 | self.__version = version 18 | self.__build_dir: Path = Path(parent_dir, 'build') 19 | 20 | def package(self): 21 | if not self.__build_dir.exists(): 22 | self.__build_dir.mkdir() 23 | cmd = f'pyinstaller {self.__bundle_type} --icon="{resource_dir}/FTT.png" --specpath "./build" --upx-dir="{resource_dir}/" --distpath "./{self.__target_dir_name}" --console --log-level INFO ./src/FTT.py' 24 | print(cmd) 25 | subprocess.call(args=cmd, cwd=parent_dir, shell=True) 26 | 27 | def copy_files(self): 28 | if not Path(self.__output_dir, 'config').exists(): 29 | shutil.copy(PurePath(parent_dir, 'config'), PurePath(self.__output_dir, 'config')) 30 | print('copied config') 31 | 32 | def archive(self): 33 | system, machine = platform.system().lower(), platform.machine().lower() 34 | print('archiving') 35 | if self.__version: 36 | output_file = f'{self.__output_dir}-{self.__version}-{system}-{machine}' 37 | else: 38 | output_file = f'{self.__output_dir}-{system}-{machine}' 39 | shutil.make_archive(output_file, 'zip', self.__output_dir) 40 | output_file = output_file + '.zip' 41 | print(f'Output file {output_file}, size: {get_size(os.path.getsize(output_file))}') 42 | os.startfile(self.__output_dir.parent, 'explore') 43 | 44 | def clean(self): 45 | print('cleaning') 46 | if self.__output_dir.exists(): 47 | shutil.rmtree(self.__output_dir) 48 | if self.__build_dir.exists(): 49 | shutil.rmtree(self.__build_dir) 50 | 51 | def main(self): 52 | try: 53 | self.package() 54 | self.copy_files() 55 | self.archive() 56 | except Exception as error: 57 | print(error) 58 | finally: 59 | self.clean() 60 | 61 | 62 | def get_size(size, factor=1024, suffix="B"): 63 | """ 64 | Scale bytes to its proper format 65 | e.g: 66 | 1253656 => '1.20MB' 67 | 1253656678 => '1.17GB' 68 | """ 69 | for data_unit in ["", "K", "M", "G", "T", "P"]: 70 | if size < factor: 71 | return f"{size:.2f}{data_unit}{suffix}" 72 | size /= factor 73 | 74 | 75 | if __name__ == '__main__': 76 | # version = '2.4.0' 77 | build = Build(folder=False, target_dir_name='FTT', version='') 78 | build.main() 79 | -------------------------------------------------------------------------------- /docs/build_guide/readme.txt: -------------------------------------------------------------------------------- 1 | ################################# 2 | # Build Guide for FTC and FTS # 3 | ################################# 4 | 5 | # Icons From https://www.iconfinder.com/, lisence: Attribution 3.0 Unported (CC BY 3.0) 6 | # Install the Pyinstaller 7 | pip install -U pyinstaller 8 | 9 | # Just run the "build.py" to build the program, the packaged file will be "FTT.zip" 10 | # 11 | # If the above process is executed successfully, 12 | # there is no need for you to continue browsing the following content 13 | 14 | ----------------------------------------------------------------- 15 | 16 | # Move to your project path or your venv path 17 | # Make sure the paths below are correct 18 | 19 | # If the packaged file is too large, you may consider placing 20 | # UPX.exe (https://upx.github.io/) to the path './docs/build_guide/upx.exe' 21 | 22 | # Package FTT.py as an executable program 23 | pyinstaller.exe --onefile --icon="../docs/build_guide/FTT.png" --specpath "./build" --upx-dir "./docs/build_guide/upx.exe" --distpath "./FTT" --console ./FTT.py 24 | 25 | -------------------------------------------------------------------------------- /docs/build_guide/upx.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CrazyMayfly/File-Transfer-Tools/b434acb5323ca437662f2a2297366ffb0bcae06e/docs/build_guide/upx.exe -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | tqdm~=4.66.1 2 | psutil~=5.9.5 3 | Send2Trash~=1.8.0 4 | pyperclip~=1.8.2 5 | pyOpenSSL~=23.2.0 6 | pyreadline3; sys_platform == "win32" 7 | -------------------------------------------------------------------------------- /src/constants.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import psutil 3 | from enum import IntEnum, StrEnum, auto 4 | from struct import Struct 5 | from typing import Final 6 | from platform import system 7 | 8 | WINDOWS: Final[str] = 'Windows' 9 | LINUX: Final[str] = 'Linux' 10 | MACOS: Final[str] = 'Macos' 11 | 12 | cur_platform: Final[str] = system() 13 | windows = cur_platform == WINDOWS 14 | 15 | username = psutil.users()[0].name 16 | cpu_count = psutil.cpu_count(logical=False) or 2 17 | 18 | LARGE_FILE_SIZE_THRESHOLD = 20 # 1024 * 1024 19 | SMALL_FILE_CHUNK_SIZE = 21 # 1024 * 1024 * 2 20 | KB = 1024 21 | MB = 1024 * KB 22 | FILE_TAIL_SIZE = 512 * KB 23 | TIME_FORMAT: Final[str] = '%Y-%m-%d %H:%M:%S' 24 | package = getattr(sys, 'frozen', False) 25 | 26 | 27 | class LEVEL(StrEnum): 28 | """ 29 | 日志打印等级的枚举类,值为等级对应的颜色代码 30 | """ 31 | LOG = '' 32 | INFO = ';34' 33 | WARNING = ';33' 34 | SUCCESS = ';32' 35 | ERROR = ';31' 36 | 37 | 38 | # 命令类型 39 | class COMMAND(IntEnum): 40 | NULL = auto() 41 | SEND_FILES_IN_FOLDER = auto() 42 | SEND_SMALL_FILE = auto() 43 | SEND_LARGE_FILE = auto() 44 | COMPARE_FOLDER = auto() 45 | FORCE_SYNC_FOLDER = auto() 46 | EXECUTE_COMMAND = auto() 47 | EXECUTE_RESULT = auto() 48 | SYSINFO = auto() 49 | SPEEDTEST = auto() 50 | BEFORE_WORKING = auto() 51 | CLOSE = auto() 52 | HISTORY = auto() 53 | COMPARE = auto() 54 | CHAT = auto() 55 | FINISH = auto() 56 | PUSH_CLIPBOARD = auto() 57 | PULL_CLIPBOARD = auto() 58 | 59 | 60 | # 控制类型 61 | class CONTROL(IntEnum): 62 | CONTINUE = 0 63 | CANCEL = -1 64 | FAIL2OPEN = -2 65 | 66 | 67 | # 其他常量 68 | FAIL: Final[str] = 'fail' 69 | GET: Final[str] = 'get' 70 | SEND: Final[str] = 'send' 71 | OVER: Final[bytes] = b'\00' 72 | utf8: Final[str] = 'utf-8' 73 | buf_size: Final[int] = 1024 * 1024 # 1MB 74 | sysinfo: Final[str] = 'sysinfo' 75 | compare: Final[str] = "compare" 76 | force_sync: Final[str] = "fsync" 77 | speedtest: Final[str] = 'speedtest' 78 | setbase: Final[str] = 'setbase' 79 | history: Final[str] = 'history' 80 | cp: Final[str] = "cp" 81 | say: Final[str] = 'say' 82 | clipboard_send: Final[str] = 'send clipboard' 83 | clipboard_get: Final[str] = 'get clipboard' 84 | commands = [sysinfo, compare, speedtest, setbase, say, history, clipboard_send, clipboard_get, force_sync] 85 | sn_commands = [sysinfo, compare, history, force_sync, cp] 86 | 87 | # Struct 对象 88 | # B为 1字节 unsigned char,0~127 89 | # Q为 8字节 unsigned long long, 0~2^64-1 90 | # q为 8字节 long long, -2^63~2^63-1 91 | # H为 2字节 unsigned short, 0~65535 92 | # d为 8字节 double, 2.3E-308~1.7E+308 93 | head_struct = Struct('>BQH') 94 | size_struct = Struct('q') 95 | times_struct = Struct('ddd') 96 | -------------------------------------------------------------------------------- /src/ftc.py: -------------------------------------------------------------------------------- 1 | import ssl 2 | import os.path 3 | import readline 4 | 5 | from pbar_manager import PbarManager 6 | from utils import * 7 | from tqdm import tqdm 8 | from sys_info import * 9 | from pathlib import Path 10 | from collections import deque 11 | from shutil import get_terminal_size 12 | 13 | 14 | def print_history(nums=10): 15 | current_length = readline.get_current_history_length() 16 | start = max(1, current_length - nums + 1) 17 | for i in range(start, current_length + 1): 18 | print(readline.get_history_item(i)) 19 | 20 | 21 | def get_dir_file_name(filepath, desc_suffix='files', position=0): 22 | """ 23 | 获取某文件路径下的所有文件夹和文件的相对路径,并显示进度条。 24 | :param desc_suffix: 描述后缀 25 | :param position: 进度条位置 26 | :param filepath: 文件路径 27 | :return: 返回该文件路径下的所有文件夹、文件的相对路径 28 | """ 29 | folders, files = {}, [] 30 | root_abs_path = os.path.abspath(filepath) 31 | queue = deque([(root_abs_path, '.')]) 32 | processed_paths = set() 33 | 34 | # 初始化进度显示 35 | pbar = tqdm(desc=f"Scanning {desc_suffix}", unit=" files", position=position, dynamic_ncols=True) 36 | 37 | while queue: 38 | current_abs_path, current_rel_path = queue.popleft() 39 | if current_abs_path in processed_paths: 40 | continue 41 | processed_paths.add(current_abs_path) 42 | 43 | stat = os.stat(current_abs_path) 44 | folders[current_rel_path] = (stat.st_atime, stat.st_mtime) 45 | 46 | try: 47 | with os.scandir(current_abs_path) as it: 48 | for entry in it: 49 | entry_rel_path = f"{current_rel_path}/{entry.name}" if current_rel_path != '.' else entry.name 50 | if entry.is_dir(follow_symlinks=False): 51 | queue.append((entry.path, entry_rel_path)) 52 | elif entry.is_file(follow_symlinks=False): 53 | files.append(entry_rel_path) 54 | pbar.update(1) 55 | except PermissionError: 56 | continue 57 | 58 | # 更新进度条描述 59 | pbar.set_postfix(folders=len(folders)) 60 | pbar.close() 61 | return folders, files 62 | 63 | 64 | def split_by_threshold(info): 65 | result = [] 66 | current_sum = last_idx = 0 67 | for idx, (_, size, _) in enumerate(info, start=1): 68 | current_sum += size 69 | if current_sum >> SMALL_FILE_CHUNK_SIZE: 70 | result.append((current_sum, idx - last_idx, info[last_idx:idx])) 71 | last_idx = idx 72 | current_sum = 0 73 | if (rest := len(info) - last_idx) > 0: 74 | result.append((current_sum, rest, info[last_idx:])) 75 | return result 76 | 77 | 78 | def alternate_first_last(input_list): 79 | """ 80 | Place the first and last elements of input list alternatively 81 | """ 82 | result = [] 83 | left, right = 0, len(input_list) - 1 84 | 85 | while left <= right: 86 | if left == right: # Handle the middle element when the list has odd length 87 | result.append(input_list[left]) 88 | else: 89 | result.append(input_list[left]) 90 | result.append(input_list[right]) 91 | left += 1 92 | right -= 1 93 | return result 94 | 95 | 96 | def collect_files_info(logger: Logger, files: set[str], root: str): 97 | # 将待发送的文件打印到日志,计算待发送的文件总大小 98 | msgs = [f'\n[INFO ] {get_log_msg("Files to be sent: ")}\n'] 99 | # 统计待发送的文件信息 100 | total_size = 0 101 | large_files_info, small_files_info = [], [] 102 | for file in tqdm(files, delay=0.1, desc='collect files info', unit='files', mininterval=0.2, leave=False): 103 | real_path = Path(root, file) 104 | file_size = (file_stat := real_path.stat()).st_size 105 | info = file, file_size, (file_stat.st_ctime, file_stat.st_mtime, file_stat.st_atime) 106 | # 记录每个文件大小 107 | large_files_info.append(info) if file_size >> LARGE_FILE_SIZE_THRESHOLD else small_files_info.append(info) 108 | total_size += file_size 109 | msgs.append(f"{real_path}, {file_size}B\n") 110 | logger.silent_write(msgs) 111 | random.shuffle(small_files_info) 112 | large_files_info = deque(alternate_first_last(sorted(large_files_info, key=lambda item: item[1]))) 113 | small_files_info = deque(split_by_threshold(small_files_info)) 114 | logger.info(f'Send files under {root}, number: {len(files)}') 115 | # 初始化总进度条 116 | pbar = tqdm(total=total_size, desc='total', unit='bytes', unit_scale=True, 117 | mininterval=1, position=0, colour='#01579B', unit_divisor=1024) 118 | return large_files_info, small_files_info, total_size, pbar 119 | 120 | 121 | class FTC: 122 | def __init__(self, ftt): 123 | self.__ftt = ftt 124 | self.__pbar: PbarManager = ... 125 | self.__base_dir: Path = ... 126 | self.__main_conn: ESocket = ftt.main_conn 127 | self.__connections: list[ESocket] = ftt.connections 128 | self.__command_prefix: str = 'powershell ' if ftt.peer_platform == WINDOWS else '' 129 | self.logger: Logger = ftt.logger 130 | self.__large_files_info: deque = deque() 131 | self.__small_files_info: deque = deque() 132 | self.__finished_files: deque = deque() 133 | 134 | def __prepare_to_compare_or_sync(self, command, is_compare: bool): 135 | prefix_length = len(compare if is_compare else force_sync) + 1 136 | folders = command[prefix_length:].split('"') 137 | folders = folders[0].split(' ') if len(folders) == 1 else \ 138 | [dir_name.strip() for dir_name in folders if dir_name.strip()] 139 | if len(folders) != 2: 140 | self.logger.warning('Local folder and peer folder cannot be empty') 141 | return 142 | 143 | local_folder, peer_folder = folders 144 | if not os.path.exists(local_folder): 145 | self.logger.warning('Local folder does not exist') 146 | return 147 | 148 | self.__main_conn.send_head(peer_folder, COMMAND.COMPARE_FOLDER if is_compare else COMMAND.FORCE_SYNC_FOLDER, 0) 149 | if self.__main_conn.recv_size() != CONTROL.CONTINUE: 150 | self.logger.warning(f"Peer folder {peer_folder} does not exist") 151 | return 152 | return folders 153 | 154 | def __compare_or_sync_folder(self, command): 155 | is_compare = command.startswith(compare) 156 | if folders := self.__prepare_to_compare_or_sync(command, is_compare): 157 | if is_compare: 158 | self.__compare_folder(*folders) 159 | else: 160 | self.__force_sync_folder(*folders) 161 | 162 | def __compare_folder(self, local_folder, peer_folder): 163 | conn: ESocket = self.__main_conn 164 | local_files_info = get_files_info_relative_to_basedir(local_folder) 165 | # 将字符串转化为dict 166 | peer_files_info: dict = conn.recv_with_decompress() 167 | # 求各种集合 168 | compare_result = compare_files_info(local_files_info, peer_files_info) 169 | msgs = print_compare_result(local_folder, peer_folder, compare_result) 170 | self.logger.silent_write(msgs) 171 | files_info_equal = compare_result[2] 172 | if not files_info_equal: 173 | conn.send_size(CONTROL.CANCEL) 174 | return 175 | command = input("Continue to compare hash for filename and size both equal set?(y/n): ").lower() 176 | if command not in ('y', 'yes'): 177 | conn.send_size(CONTROL.CANCEL) 178 | return 179 | conn.send_size(CONTROL.CONTINUE) 180 | # 发送相同的文件名称 181 | conn.send_with_compress(files_info_equal) 182 | results = FileHash.parallel_calc_hash(local_folder, files_info_equal, True) 183 | peer_files_info = conn.recv_with_decompress() 184 | hash_not_matching = [filename for filename in files_info_equal if 185 | results[filename] != peer_files_info[filename]] 186 | msg = ["hash not matching: "] + [('\t' + file_name) for file_name in hash_not_matching] 187 | print('\n'.join(msg)) 188 | files_hash_equal = [filename for filename in files_info_equal if os.path.getsize( 189 | PurePath(local_folder, filename)) >> SMALL_FILE_CHUNK_SIZE and filename not in hash_not_matching] 190 | conn.send_with_compress(files_hash_equal) 191 | if not files_hash_equal: 192 | return 193 | results = FileHash.parallel_calc_hash(local_folder, files_info_equal, False) 194 | peer_files_info = conn.recv_with_decompress() 195 | for filename in files_hash_equal: 196 | if results[filename] != peer_files_info[filename]: 197 | print('\t' + filename) 198 | msg.append('\t' + filename) 199 | if len(msg) == 1: 200 | print('\t' + 'None') 201 | msg.append('\t' + 'None') 202 | msg.append('') 203 | self.logger.silent_write(['\n'.join(msg)]) 204 | 205 | def __force_sync_folder(self, local_folder, peer_folder): 206 | """ 207 | 强制将本地文件夹的内容同步到对方文件夹,同步后双方文件夹中的文件内容一致 208 | """ 209 | conn: ESocket = self.__main_conn 210 | local_files_info = get_files_info_relative_to_basedir(local_folder) 211 | # 将字符串转化为dict 212 | peer_files_info: dict = conn.recv_with_decompress() 213 | files_smaller_than_peer, files_smaller_than_local, files_info_equal, _, file_not_exists_in_local = compare_files_info( 214 | local_files_info, peer_files_info) 215 | # 传回文件名称、大小都相等的文件信息,用于后续的文件hash比较 216 | conn.send_with_compress(files_info_equal) 217 | # 进行快速hash比较 218 | results = get_files_modified_time(local_folder, files_info_equal) 219 | peer_files_info = conn.recv_with_decompress() 220 | mtime_not_matching = [filename for filename in files_info_equal if 221 | int(results[filename]) != int(peer_files_info[filename])] 222 | msgs = ['\n[INFO ] ' + get_log_msg( 223 | f'Force sync files: local folder {local_folder} -> peer folder {peer_folder}\n')] 224 | for arg in [("files exist in peer but not in local: ", file_not_exists_in_local), 225 | ("files in local smaller than peer: ", files_smaller_than_peer), 226 | ("files in peer smaller than local: ", files_smaller_than_local)]: 227 | msgs.append(print_filename_if_exists(*arg, print_if_empty=False)) 228 | msg = ["files modified time not matching: "] 229 | if mtime_not_matching: 230 | msg.extend([ 231 | f'\t{filename}: {format_timestamp(results[filename])} <-> {format_timestamp(peer_files_info[filename])}' 232 | for filename in mtime_not_matching]) 233 | else: 234 | msg.append('\tNone') 235 | if mtime_not_matching: 236 | print('\n'.join(msg)) 237 | msg.append('') 238 | self.logger.silent_write(msgs) 239 | 240 | files_to_remove_in_peer = files_smaller_than_peer + files_smaller_than_local + file_not_exists_in_local + mtime_not_matching 241 | if len(files_to_remove_in_peer) != 0: 242 | command = input( 243 | f"Continue to force sync files in local folder({local_folder})\n" 244 | f" with above files removed in peer folder?(y/n): ").lower() 245 | if command not in ('y', 'yes'): 246 | conn.send_size(CONTROL.CANCEL) 247 | return 248 | conn.send_size(CONTROL.CONTINUE) 249 | conn.send_with_compress(files_to_remove_in_peer) 250 | self.__send_files_in_folder(local_folder, True) 251 | 252 | def __execute_command(self, command): 253 | if len(command) == 0: 254 | return 255 | if self.__ftt.peer_platform == WINDOWS and (command.startswith('cmd') or command == 'powershell'): 256 | if command == 'powershell': 257 | self.logger.info('use windows powershell') 258 | self.__command_prefix = 'powershell ' 259 | else: 260 | self.logger.info('use command prompt') 261 | self.__command_prefix = '' 262 | return 263 | command = self.__command_prefix + command 264 | conn = self.__main_conn 265 | conn.send_head(command, COMMAND.EXECUTE_COMMAND, 0) 266 | msgs = [f'\n[INFO ] {get_log_msg("Give command: ")}{command}'] 267 | # 接收返回结果 268 | result, command, _ = conn.recv_head() 269 | while command == COMMAND.EXECUTE_RESULT: 270 | print(result, end='') 271 | msgs.append(result) 272 | result, command, _ = conn.recv_head() 273 | self.logger.silent_write(msgs) 274 | 275 | def __compare_sysinfo(self): 276 | # 发送比较系统信息的命令到FTS 277 | self.__main_conn.send_head('', COMMAND.SYSINFO, 0) 278 | # 异步获取自己的系统信息 279 | thread = ThreadWithResult(get_sys_info) 280 | thread.start() 281 | # 接收对方的系统信息 282 | peer_sysinfo = self.__main_conn.recv_with_decompress() 283 | msgs = [f'[INFO ] {get_log_msg("Compare the system information of both parties: ")}\n', 284 | print_sysinfo(peer_sysinfo), print_sysinfo(thread.get_result())] 285 | # 等待本机系统信息获取完成 286 | self.logger.silent_write(msgs) 287 | 288 | def __speedtest(self, times): 289 | times = '500' if times.isspace() or not times else times 290 | while not (times.isdigit() and int(times) > 0): 291 | times = input("Please re-enter the data amount (in MB): ") 292 | times, data_unit = int(times), 1000 * 1000 # 1MB 293 | data_size = times * data_unit 294 | conn = self.__main_conn 295 | conn.send_head('', COMMAND.SPEEDTEST, data_size) 296 | start = time.time() 297 | with tqdm(total=data_size, desc='upload speedtest', unit='bytes', unit_scale=True, mininterval=1) as pbar: 298 | for i in range(times): 299 | # 生产随机字节 300 | conn.sendall(os.urandom(data_unit)) 301 | pbar.update(data_unit) 302 | show_bandwidth('Upload speed test completed', data_size, time.time() - start, self.logger) 303 | upload_over = time.time() 304 | with tqdm(total=data_size, desc='download speedtest', unit='bytes', unit_scale=True, mininterval=1) as pbar: 305 | for i in range(times): 306 | conn.recv_data(data_unit) 307 | pbar.update(data_unit) 308 | show_bandwidth('Download speed test completed', data_size, time.time() - upload_over, self.logger) 309 | 310 | def __exchange_clipboard(self, command): 311 | """ 312 | 交换(发送,获取)对方剪切板内容 313 | 314 | @param command: get 或 send 315 | @return: 316 | """ 317 | func = get_clipboard if command == GET else send_clipboard 318 | func(self.__main_conn, self.logger) 319 | 320 | def __prepare_to_send(self, folder, is_sync): 321 | self.__base_dir = folder 322 | # 发送文件夹命令 323 | if not is_sync: 324 | self.__main_conn.send_head(PurePath(folder).name, COMMAND.SEND_FILES_IN_FOLDER, 0) 325 | folders, files = get_dir_file_name(folder) 326 | # 发送文件夹数据 327 | self.__main_conn.send_with_compress(folders) 328 | # 接收对方已有的文件名并计算出对方没有的文件 329 | files = set(files) - set(self.__main_conn.recv_with_decompress()) 330 | if not files: 331 | self.__main_conn.send_size(0) 332 | self.logger.info('No files to send', highlight=1) 333 | return None 334 | large_files_info, small_files_info, total_size, pbar = collect_files_info(self.logger, files, folder) 335 | self.__main_conn.send_size(total_size) 336 | self.__large_files_info = large_files_info 337 | self.__small_files_info = small_files_info 338 | self.__pbar = PbarManager(pbar) 339 | return files 340 | 341 | def __send_files_in_folder(self, folder, is_sync=False): 342 | if self.__ftt.busy_lock.locked(): 343 | self.logger.warning('Currently receiving/sending folder, please try again later.', highlight=1) 344 | return 345 | with self.__ftt.busy_lock: 346 | if not (files := self.__prepare_to_send(folder, is_sync)): 347 | return 348 | # 发送文件 349 | futures = [self.__ftt.executor.submit(self.__send_file, conn, position) for position, conn in 350 | enumerate(self.__connections, start=1)] 351 | for future in futures: 352 | while not future.done(): 353 | time.sleep(0.2) 354 | 355 | fails = files - set(self.__finished_files) 356 | self.__finished_files.clear() 357 | # 比对发送失败的文件 358 | self.__pbar.set_status(len(fails) > 0) 359 | if fails: 360 | self.logger.error("Failed to sent: ", highlight=1) 361 | for fail in fails: 362 | self.logger.warning(fail) 363 | errors = [future.exception() for future in futures] 364 | if errors.count(None) != len(errors): 365 | errors = '\n'.join([f'Thread-{idx}: {exception}' for idx, exception in enumerate(errors) if exception]) 366 | self.logger.error(f"Exceptions occurred during this sending: \n{errors}", highlight=1) 367 | 368 | def __send_single_file(self, file: Path): 369 | self.logger.silent_write([f'\n[INFO ] {get_log_msg(f"Send a single file: {file}")}\n']) 370 | self.__base_dir = file.parent 371 | file_size = (file_stat := file.stat()).st_size 372 | time_info = file_stat.st_ctime, file_stat.st_mtime, file_stat.st_atime 373 | self.__large_files_info.append((file.name, file_size, time_info)) 374 | pbar_width = get_terminal_size().columns / 4 375 | self.__pbar = PbarManager(tqdm(total=file_size, desc=shorten_path(file.name, pbar_width), unit='bytes', 376 | unit_scale=True, mininterval=1, position=0, colour='#01579B', unit_divisor=1024)) 377 | try: 378 | self.__send_large_files(self.__main_conn, 0) 379 | except (ssl.SSLError, ConnectionError) as error: 380 | self.logger.error(error) 381 | finally: 382 | is_success = len(self.__finished_files) and self.__finished_files.pop() == file.name 383 | self.__pbar.set_status(not is_success) 384 | self.logger.success(f"{file} sent successfully") if is_success else self.logger.error(f"{file} failed to send") 385 | 386 | def __send_large_files(self, conn: ESocket, position: int): 387 | while len(self.__large_files_info): 388 | filename, file_size, time_info = self.__large_files_info.pop() 389 | real_path = PurePath(self.__base_dir, filename) 390 | try: 391 | fp = open(real_path, 'rb') 392 | except FileNotFoundError: 393 | self.logger.error(f'Failed to open: {real_path}') 394 | continue 395 | conn.send_head(filename, COMMAND.SEND_LARGE_FILE, file_size) 396 | if (flag := conn.recv_size()) == CONTROL.FAIL2OPEN: 397 | self.logger.error(f'Peer failed to receive the file: {real_path}', highlight=1) 398 | return 399 | # 服务端已有的文件大小 400 | fp.seek(peer_exist_size := flag, 0) 401 | rest_size = file_size - peer_exist_size 402 | pbar_width = get_terminal_size().columns / 4 403 | with tqdm(total=rest_size, desc=shorten_path(filename, pbar_width), unit='bytes', unit_scale=True, 404 | mininterval=1, position=position, leave=False, disable=position == 0, unit_divisor=1024) as pbar: 405 | while rest_size > 0: 406 | sent_size = conn.sendfile(fp, offset=fp.tell(), count=5 * MB) 407 | rest_size -= sent_size 408 | pbar.update(sent_size) 409 | self.__pbar.update(sent_size) 410 | fp.close() 411 | # 发送文件的创建、访问、修改时间戳 412 | conn.sendall(times_struct.pack(*time_info)) 413 | self.__pbar.update(peer_exist_size, decrease=True) 414 | self.__finished_files.append(filename) 415 | 416 | def __send_small_files(self, conn: ESocket, position: int): 417 | idx, real_path, files_info = 0, Path(""), [] 418 | while len(self.__small_files_info): 419 | try: 420 | total_size, num, files_info = self.__small_files_info.pop() 421 | conn.send_head('', COMMAND.SEND_SMALL_FILE, total_size) 422 | conn.send_with_compress(files_info) 423 | with tqdm(total=total_size, desc=f'{num} small files', unit='bytes', unit_scale=True, 424 | mininterval=0.2, position=position, leave=False, unit_divisor=1024) as pbar: 425 | for idx, (filename, file_size, _) in enumerate(files_info): 426 | real_path = Path(self.__base_dir, filename) 427 | with real_path.open('rb') as fp: 428 | conn.sendfile(fp) 429 | pbar.update(file_size) 430 | self.__pbar.update(total_size) 431 | except FileNotFoundError: 432 | self.logger.error(f'Failed to open: {real_path}') 433 | finally: 434 | self.__finished_files.extend([filename for filename, _, _ in files_info[:idx + 1]]) 435 | 436 | def __send_file(self, conn: ESocket, position: int): 437 | try: 438 | if position < 3: 439 | self.__send_large_files(conn, position) 440 | self.__send_small_files(conn, position) 441 | else: 442 | self.__send_small_files(conn, position) 443 | self.__send_large_files(conn, position) 444 | finally: 445 | conn.send_head('', COMMAND.FINISH, 0) 446 | 447 | def execute(self, command): 448 | if command == sysinfo: 449 | self.__compare_sysinfo() 450 | elif command.startswith(speedtest): 451 | self.__speedtest(times=command[10:]) 452 | elif command.startswith((compare, force_sync)): 453 | self.__compare_or_sync_folder(command) 454 | elif command.startswith(say): 455 | self.__main_conn.send_head(command[4:], COMMAND.CHAT, 0) 456 | elif command.endswith('clipboard'): 457 | self.__exchange_clipboard(command.split()[0]) 458 | elif command.startswith(history): 459 | print_history(int(command.split()[1])) if len(command.split()) > 1 and command.split()[ 460 | 1].isdigit() else print_history() 461 | else: 462 | paths = command.split('|') 463 | # 由于判断是否为发送文件夹,若不为则执行命令 464 | flag = True 465 | path_not_exists = [] 466 | for path in paths: 467 | if os.path.exists(path): 468 | flag = False 469 | if os.path.isdir(path): 470 | self.__send_files_in_folder(path) 471 | else: 472 | self.__send_single_file(Path(path)) 473 | else: 474 | path_not_exists.append(path) 475 | if flag: 476 | self.__execute_command(command) 477 | elif len(path_not_exists): 478 | for path in path_not_exists: 479 | self.logger.warning(f'Path does not exist: {path}, skipped.', highlight=1) 480 | -------------------------------------------------------------------------------- /src/fts.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import subprocess 3 | import concurrent.futures 4 | 5 | import send2trash 6 | 7 | from utils import * 8 | from sys_info import * 9 | from pathlib import Path 10 | 11 | 12 | def avoid_filename_duplication(filename: str): 13 | """ 14 | 当文件重复时另取新的文件名 15 | 16 | @param filename: 原文件名 17 | @return: 新文件名 18 | """ 19 | if os.path.exists(filename): 20 | i = 1 21 | base, extension = os.path.splitext(filename) 22 | while os.path.exists(filename): 23 | filename = f"{base}({i}){extension}" 24 | i += 1 25 | return filename 26 | 27 | 28 | class FTS: 29 | def __init__(self, ftt): 30 | self.__ftt = ftt 31 | self.__main_conn: ESocket = ftt.main_conn_recv 32 | self.logger: Logger = ftt.logger 33 | 34 | def __compare_folder(self, folder): 35 | # self.logger.info(f"Client request to compare folder: {folder}") 36 | if not os.path.exists(folder): 37 | # 发送目录不存在 38 | self.__main_conn.send_size(CONTROL.CANCEL) 39 | return 40 | self.__main_conn.send_size(CONTROL.CONTINUE) 41 | # 将数组拼接成字符串发送到客户端 42 | self.__main_conn.send_with_compress(get_files_info_relative_to_basedir(folder)) 43 | if self.__main_conn.recv_size() != CONTROL.CONTINUE: 44 | return 45 | file_size_and_name_both_equal = self.__main_conn.recv_with_decompress() 46 | # 得到文件相对路径名: hash值字典 47 | results = FileHash.parallel_calc_hash(folder, file_size_and_name_both_equal, True) 48 | self.__main_conn.send_with_compress(results) 49 | files_hash_equal = self.__main_conn.recv_with_decompress() 50 | if not files_hash_equal: 51 | return 52 | results = FileHash.parallel_calc_hash(folder, file_size_and_name_both_equal, False) 53 | self.__main_conn.send_with_compress(results) 54 | 55 | def __force_sync_folder(self, folder): 56 | if not os.path.exists(folder): 57 | # 发送目录不存在 58 | self.__main_conn.send_size(CONTROL.CANCEL) 59 | return 60 | self.__main_conn.send_size(CONTROL.CONTINUE) 61 | self.logger.info(f"Peer request to force sync folder: {folder}") 62 | # 将数组拼接成字符串发送到客户端 63 | self.__main_conn.send_with_compress(get_files_info_relative_to_basedir(folder)) 64 | # 得到文件相对路径名: hash值字典 65 | file_info_equal = self.__main_conn.recv_with_decompress() 66 | self.__main_conn.send_with_compress(get_files_modified_time(folder, file_info_equal)) 67 | if self.__main_conn.recv_size() != CONTROL.CONTINUE: 68 | self.logger.info("Peer canceled the sync.") 69 | return 70 | files_to_remove: list = self.__main_conn.recv_with_decompress() 71 | self.logger.silent_write([print_filename_if_exists('Files to be removed:', files_to_remove, False)]) 72 | for file_rel_path in files_to_remove: 73 | try: 74 | send2trash.send2trash(PurePath(folder, file_rel_path)) 75 | except Exception as e: 76 | self.logger.warning(f'Failed to remove {file_rel_path}, reason: {e}') 77 | self.__recv_files_in_folder(Path(folder)) 78 | 79 | def __execute_command(self, command): 80 | out = subprocess.Popen(args=command, shell=True, text=True, stdout=subprocess.PIPE, 81 | stderr=subprocess.STDOUT).stdout 82 | output = [f'[LOG ] {get_log_msg("Execute command")}: {command}'] 83 | while result := out.readline(): 84 | self.__main_conn.send_head(result, COMMAND.EXECUTE_RESULT, 0) 85 | output.append(result) 86 | # 命令执行结束 87 | self.__main_conn.send_head('', COMMAND.FINISH, 0) 88 | self.logger.silent_write(output) 89 | 90 | def __speedtest(self, data_size): 91 | self.logger.info(f"Client request speed test, size: {get_size(2 * data_size, factor=1000)}") 92 | start = time.time() 93 | data_unit = 1000 * 1000 94 | for i in range(0, int(data_size / data_unit)): 95 | self.__main_conn.recv_data(data_unit) 96 | show_bandwidth('Download speed test completed', data_size, time.time() - start, self.logger) 97 | download_over = time.time() 98 | for i in range(0, int(data_size / data_unit)): 99 | self.__main_conn.sendall(os.urandom(data_unit)) 100 | show_bandwidth('Upload speed test completed', data_size, time.time() - download_over, self.logger) 101 | 102 | def __recv_files_in_folder(self, cur_dir: Path): 103 | with self.__ftt.busy_lock: 104 | files = [] 105 | if cur_dir.exists(): 106 | for path, _, file_list in os.walk(cur_dir): 107 | files += [PurePath(PurePath(path).relative_to(cur_dir), file).as_posix() for file in file_list] 108 | dirs_info: dict = self.__main_conn.recv_with_decompress() 109 | makedirs(self.logger, list(dirs_info.keys()), cur_dir) 110 | # 发送已存在的文件名 111 | self.__main_conn.send_with_compress(files) 112 | start, total_size = time.time(), self.__main_conn.recv_size() 113 | if not total_size: 114 | self.logger.info('No files to receive') 115 | return 116 | futures = [self.__ftt.executor.submit(self.__slave_work, conn, cur_dir) for conn in self.__ftt.connections] 117 | concurrent.futures.wait(futures) 118 | 119 | for dir_name, times in dirs_info.items(): 120 | folder = PurePath(cur_dir, dir_name) 121 | try: 122 | os.utime(path=folder, times=times) 123 | except Exception as error: 124 | self.logger.warning(f'Folder {cur_dir} time modification failed, {error}', highlight=1) 125 | show_bandwidth('Received folder', total_size, time.time() - start, self.logger, LEVEL.INFO) 126 | 127 | def __recv_small_files(self, conn: ESocket, cur_dir, files_info): 128 | real_path = Path("") 129 | try: 130 | msgs = [] 131 | for filename, file_size, time_info in files_info: 132 | real_path = Path(cur_dir, filename) 133 | real_path.write_bytes(conn.recv_data(file_size)) 134 | modify_file_time(self.logger, str(real_path), *time_info) 135 | msgs.append(f'[SUCCESS] {get_log_msg("Received")}: {real_path}\n') 136 | self.logger.success(f'Received: {len(files_info)} small files') 137 | self.logger.silent_write(msgs) 138 | except ConnectionDisappearedError: 139 | self.logger.warning(f'Connection was terminated unexpectedly and reception failed: {real_path}') 140 | except FileNotFoundError: 141 | self.logger.warning(f'File creation/opening failed that cannot be received: {real_path}', highlight=1) 142 | 143 | def __recv_large_file(self, conn: ESocket, cur_dir, filename, file_size): 144 | original_file = avoid_filename_duplication(str(PurePath(cur_dir, filename))) 145 | cur_download_file = f'{original_file}.ftsdownload' 146 | try: 147 | with open(cur_download_file, 'ab') as fp: 148 | conn.send_size(size := os.path.getsize(cur_download_file)) 149 | rest_size = file_size - size 150 | while rest_size >> 12: 151 | data, size = conn.recv() 152 | fp.write(data) 153 | rest_size -= size 154 | fp.write(conn.recv_data(rest_size)) 155 | os.rename(cur_download_file, original_file) 156 | self.logger.success(f'Received: {original_file}') 157 | timestamps = times_struct.unpack(conn.recv_data(times_struct.size)) 158 | modify_file_time(self.logger, original_file, *timestamps) 159 | except ConnectionDisappearedError: 160 | self.logger.warning(f'Connection was terminated unexpectedly and reception failed: {original_file}') 161 | except PermissionError as err: 162 | self.logger.warning(f'Failed to rename: {cur_download_file} -> {original_file}, {err}') 163 | except FileNotFoundError: 164 | self.logger.warning(f'File creation/opening failed that cannot be received: {original_file}', highlight=1) 165 | conn.sendall(size_struct.pack(CONTROL.FAIL2OPEN)) 166 | 167 | def __slave_work(self, conn: ESocket, cur_dir): 168 | """ 169 | 从连接的工作,只用于处理多文件接收 170 | 171 | @param conn: 从连接 172 | """ 173 | try: 174 | while True: 175 | filename, command, file_size = conn.recv_head() 176 | if command == COMMAND.SEND_LARGE_FILE: 177 | self.__recv_large_file(conn, cur_dir, filename, file_size) 178 | elif command == COMMAND.SEND_SMALL_FILE: 179 | self.__recv_small_files(conn, cur_dir, conn.recv_with_decompress()) 180 | elif command == COMMAND.FINISH: 181 | break 182 | except ConnectionError: 183 | return 184 | except Exception as e: 185 | msg = 'Peer data flow abnormality, connection disconnected' if isinstance(e, UnicodeDecodeError) else str(e) 186 | self.logger.error(msg, highlight=1) 187 | 188 | def execute(self, filename, command, file_size): 189 | """ 190 | 主连接的工作 191 | """ 192 | match command: 193 | case COMMAND.SEND_FILES_IN_FOLDER: 194 | self.logger.info(f'Receiving folder: {filename}') 195 | self.__recv_files_in_folder(Path(self.__ftt.base_dir, filename)) 196 | case COMMAND.SEND_LARGE_FILE: 197 | self.logger.info(f'Receiving single file: {filename}, size: {get_size(file_size)}') 198 | self.__recv_large_file(self.__main_conn, self.__ftt.base_dir, filename, file_size) 199 | case COMMAND.COMPARE_FOLDER: 200 | self.__compare_folder(filename) 201 | case COMMAND.FORCE_SYNC_FOLDER: 202 | self.__force_sync_folder(filename) 203 | case COMMAND.EXECUTE_COMMAND: 204 | self.__execute_command(filename) 205 | case COMMAND.SYSINFO: 206 | self.__main_conn.send_with_compress(get_sys_info()) 207 | case COMMAND.SPEEDTEST: 208 | self.__speedtest(file_size) 209 | case COMMAND.CHAT: 210 | self.logger.log(f'{self.__ftt.peer_username} said: {filename}') 211 | case COMMAND.PULL_CLIPBOARD: 212 | send_clipboard(self.__main_conn, self.logger, ftc=False) 213 | case COMMAND.PUSH_CLIPBOARD: 214 | get_clipboard(self.__main_conn, self.logger, filename, command, file_size, ftc=False) 215 | -------------------------------------------------------------------------------- /src/ftt.py: -------------------------------------------------------------------------------- 1 | import signal 2 | import struct 3 | import select 4 | 5 | from ftt_lib import * 6 | from ftt_base import FTTBase 7 | from ftt_sn import FTTSn 8 | 9 | 10 | class FTT(FTTBase): 11 | def __init__(self, password, host, base_dir, threads): 12 | super().__init__(threads, False) 13 | self.peer_username: str = ... 14 | self.peer_platform: str = ... 15 | self.base_dir: Path = base_dir.expanduser().absolute() 16 | self.main_conn_recv: ESocket = ... 17 | self.main_conn: ESocket = ... 18 | self.busy_lock: threading.Lock = threading.Lock() 19 | self.connections: list[ESocket] = [] 20 | self.__ftc: FTC = ... 21 | self.__fts: FTS = ... 22 | self.__host: str = host 23 | self.__alive: bool = True 24 | self.__password: str = password 25 | 26 | def __change_base_dir(self, new_base_dir: str): 27 | """ 28 | 切换FTS的文件保存目录 29 | """ 30 | if not new_base_dir or new_base_dir.isspace(): 31 | return 32 | new_base_dir = Path(new_base_dir).expanduser().absolute() 33 | if new_base_dir.is_file(): 34 | self.logger.error(f'{new_base_dir} is a file, please input a directory') 35 | return 36 | if self.create_folder_if_not_exist(new_base_dir): 37 | self.base_dir = new_base_dir 38 | self.logger.success(f'File save location changed to: {new_base_dir}') 39 | 40 | def __connect(self): 41 | try: 42 | context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) 43 | context.check_hostname = False 44 | context.verify_mode = ssl.CERT_NONE 45 | voucher = self.__password.encode() + self.__first_connect(context, self.__host) 46 | for i in range(0, self.threads + 1): 47 | client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 48 | client_socket.connect((self.__host, config.server_port)) 49 | client_socket = ESocket(context.wrap_socket(client_socket, server_hostname='FTS')) 50 | client_socket.sendall(voucher) 51 | self.connections.append(client_socket) 52 | self.main_conn_recv = self.connections.pop() 53 | except (ssl.SSLError, OSError) as msg: 54 | self.logger.error(f'Failed to connect to the server {self.__host}, {msg}') 55 | pause_before_exit(-1) 56 | 57 | def __first_connect(self, context, host): 58 | client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 59 | # 连接至服务器 60 | client_socket.connect((host, config.server_port)) 61 | # 将socket包装为securitySocket 62 | client_socket = ESocket(context.wrap_socket(client_socket, server_hostname='FTS')) 63 | client_socket.send_head(f'{self.__password}', COMMAND.BEFORE_WORKING, self.threads) 64 | client_socket.send_head(f'{cur_platform}_{username}', COMMAND.BEFORE_WORKING, 0) 65 | client_socket.sendall(connect_id := os.urandom(64)) 66 | msg, _, threads = client_socket.recv_head() 67 | if msg == FAIL: 68 | self.logger.error('Wrong password to connect to server', highlight=1) 69 | client_socket.close() 70 | pause_before_exit(-1) 71 | # self.logger.info(f'服务器所在平台: {msg}\n') 72 | self.peer_platform, *peer_username = msg.split('_') 73 | if self.threads != threads: 74 | self.logger.info(f"Thread count mismatch, use a lower value: {min(self.threads, threads)}") 75 | self.threads = min(self.threads, threads) 76 | self.peer_username = '_'.join(peer_username) 77 | self.main_conn = client_socket 78 | return connect_id 79 | 80 | def _shutdown(self, send_info=True): 81 | try: 82 | if send_info: 83 | self.main_conn.send_head('', COMMAND.CLOSE, 0) 84 | for conn in self.connections + [self.main_conn_recv, self.main_conn]: 85 | if conn is not ...: 86 | conn.close() 87 | except (ssl.SSLEOFError, ConnectionError): 88 | pass 89 | finally: 90 | self.logger.close() 91 | self._history_file.close() 92 | if package: 93 | os.system('pause') 94 | os.kill(os.getpid(), signal.SIGINT) 95 | 96 | def __verify_connection(self, conn: ESocket): 97 | peer_ip, peer_port = conn.getpeername() 98 | conn.settimeout(4) 99 | try: 100 | password, command, threads = conn.recv_head() 101 | info, _, _ = conn.recv_head() 102 | peer_platform, *peer_username = info.split('_') 103 | except (TimeoutError, struct.error) as error: 104 | conn.close() 105 | self.logger.warning(('Client {}:{} failed to verify the password in time' if isinstance(error, TimeoutError) 106 | else 'Encountered unknown connection {}:{}').format(peer_ip, peer_port)) 107 | return 108 | conn.settimeout(None) 109 | if command != COMMAND.BEFORE_WORKING: 110 | conn.close() 111 | return 112 | # 校验密码, 密码正确则发送当前平台 113 | msg = FAIL if password != self.__password else f'{cur_platform}_{username}' 114 | conn.send_head(msg, COMMAND.BEFORE_WORKING, self.threads) 115 | if password != self.__password: 116 | conn.close() 117 | self.logger.warning(f'Client {peer_ip}:{peer_port} password("{password}") is wrong') 118 | return 119 | 120 | self.peer_platform = peer_platform 121 | self.peer_username = '_'.join(peer_username) 122 | if self.threads != threads: 123 | self.logger.info(f"Thread count mismatch, use a lower value: {min(self.threads, threads)}") 124 | self.threads = min(self.threads, threads) 125 | return self.__password.encode() + conn.recv_data(64) 126 | 127 | def __waiting_connect(self, ip): 128 | server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 129 | server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 130 | try: 131 | server_socket.bind(('0.0.0.0', config.server_port)) 132 | except (OSError, PermissionError): 133 | server_socket.bind((ip, config.server_port)) 134 | server_socket.listen(100) 135 | # 加载服务器所用证书和私钥 136 | context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) 137 | context.load_cert_chain(cert_path := generate_cert()) 138 | os.remove(cert_path) 139 | peer_ip, voucher = None, None 140 | while True: 141 | try: 142 | if not select.select([server_socket], [], [], 0.2)[0]: 143 | continue 144 | conn, (peer_ip, _) = server_socket.accept() 145 | conn = ESocket(context.wrap_socket(conn, server_side=True)) 146 | if voucher := self.__verify_connection(conn): 147 | self.main_conn_recv = conn 148 | self.__host = peer_ip 149 | break 150 | except KeyboardInterrupt: 151 | self._shutdown(send_info=False) 152 | while len(self.connections) < self.threads + 1: 153 | try: 154 | if not select.select([server_socket], [], [], 0.1)[0]: 155 | continue 156 | conn, (ip, port) = server_socket.accept() 157 | conn = ESocket(context.wrap_socket(conn, server_side=True)) 158 | if ip != peer_ip or conn.recv_data(len(voucher)) != voucher: 159 | continue 160 | self.connections.append(conn) 161 | except ssl.SSLError as e: 162 | self.logger.warning(f'SSLError: {e.reason}') 163 | except TimeoutError: 164 | self.logger.warning(f'Connection timeout') 165 | except KeyboardInterrupt: 166 | self._shutdown(send_info=False) 167 | server_socket.close() 168 | self.main_conn = self.connections.pop() 169 | 170 | def __find_server(self, ip): 171 | sk = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, 0) 172 | try: 173 | try: 174 | sk.bind(('0.0.0.0', config.signal_port)) 175 | except (OSError, PermissionError): 176 | sk.bind((ip, config.signal_port)) 177 | content = f'HI-THERE-IS-FTT_{username}_{ip}_{config.signal_port}'.encode(utf8) 178 | # 先广播自己信息 179 | broadcast_to_all_interfaces(sk, content=content) 180 | while True: 181 | if not select.select([sk], [], [], 0.2)[0]: 182 | continue 183 | data = sk.recv(1024).decode(utf8).split('_') 184 | if data[0] == 'HI-THERE-IS-FTT': 185 | *target_username, target_ip, target_port = data[1:] 186 | if target_ip == ip: 187 | continue 188 | target_username = '_'.join(target_username) 189 | self.logger.info(f'Received probe request from {target_username}({target_ip})') 190 | sk.sendto(f'FTT-CONNECT-REQUEST'.encode(), (target_ip, int(target_port))) # 单播 191 | sk.close() 192 | self.__host = target_ip 193 | self.__connect() 194 | break 195 | elif data[0] == 'FTT-CONNECT-REQUEST': 196 | sk.close() 197 | self.__waiting_connect(ip) 198 | break 199 | except OSError as e: 200 | self.logger.error(f'Failed to start the broadcast service: {e.strerror}') 201 | pause_before_exit(-1) 202 | except KeyboardInterrupt: 203 | self.logger.close() 204 | self._history_file.close() 205 | pause_before_exit() 206 | 207 | def _boot(self): 208 | threading.Thread(name='ArchThread', target=self._compress_log_files, daemon=True).start() 209 | if self.__host: 210 | # 处理ip和端口 211 | if len(splits := self.__host.split(":")) == 2: 212 | self.__host, config.server_port = splits[0], int(splits[1]) 213 | self.__connect() 214 | else: 215 | ip = get_ip() 216 | self.logger.log(f'Server {username}({ip}:{config.server_port}) started, waiting for connection...') 217 | if self.__password: 218 | self.__waiting_connect(ip) 219 | else: 220 | self.__find_server(ip) 221 | self.logger.success(f'Connected to peer {self.peer_username}({self.__host}:{config.server_port})') 222 | self.executor = concurrent.futures.ThreadPoolExecutor(thread_name_prefix=compact_ip(self.__host)) 223 | self.__ftc, self.__fts = FTC(ftt=self), FTS(ftt=self) 224 | threading.Thread(name='SeverThread', target=self.__server, daemon=True).start() 225 | self.logger.info(f'Current threads: {self.threads}') 226 | self.logger.log(f'Current file storage location: {os.path.normcase(self.base_dir)}') 227 | 228 | def __server(self): 229 | try: 230 | while self.__alive: 231 | filename, command, file_size = self.main_conn_recv.recv_head() 232 | if command == COMMAND.CLOSE: 233 | self.logger.info(f'Peer closed connections') 234 | break 235 | self.__fts.execute(filename, command, file_size) 236 | except (ConnectionDisappearedError, ssl.SSLEOFError) as e: 237 | if self.__alive: 238 | self.logger.error(f'{e}') 239 | except ConnectionResetError as e: 240 | if self.__alive: 241 | self.logger.error(f'{e.strerror}') 242 | except UnicodeDecodeError: 243 | self.logger.error(f'Peer data flow abnormality, connection disconnected') 244 | finally: 245 | if not self.busy_lock.locked(): 246 | self._shutdown(send_info=False) 247 | else: 248 | self.__alive = False 249 | 250 | def start(self): 251 | self._boot() 252 | try: 253 | while self.__alive: 254 | command = input('> ').strip() 255 | if not command: 256 | continue 257 | self._add_history(command) 258 | if command in ['q', 'quit', 'exit']: 259 | self.__alive = False 260 | break 261 | elif command.startswith(setbase): 262 | self.__change_base_dir(command[8:]) 263 | continue 264 | self.__ftc.execute(command) 265 | except (ssl.SSLError, ConnectionError) as e: 266 | self.logger.error(e.strerror if e.strerror else e, highlight=1) 267 | finally: 268 | self._shutdown() 269 | 270 | 271 | if __name__ == '__main__': 272 | args = get_args() 273 | ftt: FTTBase = FTT(password=args.password, host=args.host, base_dir=args.dest, 274 | threads=args.t) if not args.single else FTTSn(threads=args.t) 275 | ftt.start() 276 | -------------------------------------------------------------------------------- /src/ftt_base.py: -------------------------------------------------------------------------------- 1 | import tarfile 2 | 3 | from ftt_lib import * 4 | from send2trash import send2trash 5 | 6 | 7 | class FTTBase: 8 | def __init__(self, threads: int, single_mode: bool): 9 | self.threads: int = threads 10 | self.executor: concurrent.futures.ThreadPoolExecutor = ... 11 | self._history_file: TextIO = open(read_line_setup(single_mode), 'a', encoding=utf8) 12 | self.logger: Logger = Logger(PurePath(config.log_dir, f'{datetime.now():%Y_%m_%d}_ftt.log')) 13 | 14 | def _add_history(self, command: str): 15 | readline.add_history(command) 16 | self._history_file.write(command + '\n') 17 | self._history_file.flush() 18 | 19 | def create_folder_if_not_exist(self, folder: Path) -> bool: 20 | """ 21 | 创建文件夹 22 | @param folder: 文件夹路径 23 | @return: 是否创建成功 24 | """ 25 | if folder.exists(): 26 | return True 27 | try: 28 | folder.mkdir(parents=True) 29 | except OSError as error: 30 | self.logger.error(f'Failed to create {folder}, {error}', highlight=1) 31 | return False 32 | self.logger.info(f'Created {folder}') 33 | return True 34 | 35 | def _compress_log_files(self): 36 | """ 37 | 压缩日志文件 38 | @return: 39 | """ 40 | base_dir = config.log_dir 41 | if not os.path.exists(base_dir): 42 | return 43 | # 获取非今天的日志文件名 44 | today = datetime.now().strftime('%Y_%m_%d') 45 | pattern = r'^\d{4}_\d{2}_\d{2}_ftt.log' 46 | files = [entry for entry in os.scandir(base_dir) if entry.is_file() and re.match(pattern, entry.name) 47 | and not entry.name.startswith(today)] 48 | total_size = sum([file.stat().st_size for file in files]) 49 | if len(files) < config.log_file_archive_count and total_size < config.log_file_archive_size: 50 | return 51 | dates = [datetime.strptime(file.name[0:10], '%Y_%m_%d') for file in files] 52 | max_date, min_date = max(dates), min(dates) 53 | # 压缩后的输出文件名 54 | output_file = PurePath(base_dir, f'{min_date:%Y%m%d}_{max_date:%Y%m%d}.ftt.tar.gz') 55 | # 创建一个 tar 归档文件对象 56 | with tarfile.open(output_file, 'w:gz') as tar: 57 | # 逐个添加文件到归档文件中 58 | for file in files: 59 | tar.add(file.path, arcname=file.name) 60 | # 若压缩文件完整则将日志文件移入回收站 61 | if tarfile.is_tarfile(output_file): 62 | for file in files: 63 | try: 64 | send2trash(PurePath(file.path)) 65 | except Exception as error: 66 | self.logger.warning(f'{error}: {file.name} failed to be sent to the recycle bin, delete it.') 67 | os.remove(file.path) 68 | 69 | self.logger.success(f'Logs archiving completed: {min_date:%Y/%m/%d} to {max_date:%Y/%m/%d}, ' 70 | f'{get_size(total_size)} -> {get_size(os.path.getsize(output_file))}') 71 | 72 | def _boot(self): 73 | pass 74 | 75 | def _shutdown(self): 76 | pass 77 | 78 | def start(self): 79 | pass 80 | -------------------------------------------------------------------------------- /src/ftt_lib.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os.path 3 | import tempfile 4 | import ipaddress 5 | 6 | from ftc import * 7 | from fts import * 8 | from OpenSSL import crypto 9 | from dataclasses import dataclass 10 | from functools import cache 11 | from argparse import Namespace, ArgumentParser 12 | from configparser import ConfigParser, NoOptionError, NoSectionError, ParsingError 13 | 14 | 15 | def get_args() -> Namespace: 16 | """ 17 | 获取命令行参数解析器 18 | """ 19 | epilog = """ 20 | commands: 21 | > file/folder: Send single file or entire folder to peer. 22 | example: D:\\test.txt 23 | > [command]: Execute command on peer. 24 | example: ipconfig 25 | > speedtest [size]: Test the network speed between two sides, size is optional in MB, default is 500MB. 26 | example: speedtest 1000 27 | > sysinfo: Get system information in both side. 28 | > history: Get the command history. 29 | > say [message]: Send a message to peer. 30 | example: say hello 31 | > setbase [folder]: Set the base folder to receive files. 32 | example: setbase "D:\\test" 33 | > send clipboard: Send the clipboard content to peer. 34 | > get clipboard: Get the clipboard content from peer. 35 | > compare "source folder" "target folder": Compare the files in the folder with the target. 36 | example: compare "D:\\source\\test" "D:\\target\\test" 37 | > fsync "source folder" "target folder": Force synchronize folder, it makes the target folder same as source folder. 38 | example: fsync "D:\\source\\test" "D:\\target\\test" 39 | > cp "source folder" "target folder": Only work in single mode, copy the source folder to target folder. 40 | """ 41 | parser = ArgumentParser(description="File Transfer Tool, used to transfer files or execute commands.", prog="ftt", 42 | epilog=epilog, formatter_class=argparse.RawDescriptionHelpFormatter) 43 | parser.add_argument('-t', metavar='thread', type=int, 44 | help=f'Threads (default: {cpu_count})', default=cpu_count) 45 | parser.add_argument('-host', metavar='host', 46 | help='Destination hostname or ip address', default='') 47 | parser.add_argument('-p', '--password', metavar='password', type=str, 48 | help='Set a password for the host or Use a password to connect host.', default='') 49 | parser.add_argument('-s', '--single', action='store_true', dest='single', 50 | help='Use the single node mode. In this mode, params like -p, -d, -host will not work.') 51 | parser.add_argument('-d', '--dest', metavar='base_dir', type=Path, 52 | help='File save location (default: {})'.format(config.default_path), 53 | default=config.default_path) 54 | return parser.parse_args() 55 | 56 | complete_commands = [] 57 | @cache 58 | def get_matches(line: str): 59 | matches = [command + ' ' for command in complete_commands if command.startswith(line)] 60 | if not line: 61 | return matches 62 | path, remainder = os.path.split(line) 63 | for command in complete_commands: 64 | if line.startswith(f"{command} "): 65 | path, remainder = os.path.split(line[len(command) + 1:]) 66 | break 67 | if remainder == '..': 68 | matches += [remainder + os.sep] 69 | else: 70 | folders, files = [], [] 71 | path = path or '.' 72 | try: 73 | for entry in os.scandir(path): 74 | if (name := entry.name).startswith(remainder): 75 | folders.append(name + os.sep) if entry.is_dir() else files.append(name) 76 | matches += folders + files 77 | except (FileNotFoundError, PermissionError): 78 | pass 79 | return matches 80 | 81 | 82 | def completer(_, state): 83 | matches = get_matches(readline.get_line_buffer()) 84 | return matches[state] if state < len(matches) else None 85 | 86 | 87 | def read_line_setup(single_mode: bool) -> Path: 88 | """ 89 | 设置readline的补全和历史记录功能 90 | """ 91 | global complete_commands 92 | complete_commands = sn_commands if single_mode else commands 93 | readline.set_completer(completer) 94 | readline.set_history_length(1000) 95 | readline.parse_and_bind('tab: complete') 96 | history_filename = Path(config.log_dir, 'history.txt') 97 | if history_filename.exists(): 98 | readline.read_history_file(history_filename) 99 | return history_filename 100 | 101 | 102 | def generate_cert(): 103 | # 生成密钥对 104 | key = crypto.PKey() 105 | key.generate_key(crypto.TYPE_RSA, 2048) 106 | # 生成自签名证书 107 | cert = crypto.X509() 108 | cert.get_subject().CN = "FTS" 109 | cert.set_serial_number(random.randint(1, 9999)) 110 | cert.gmtime_adj_notBefore(0) 111 | cert.gmtime_adj_notAfter(100) 112 | cert.set_pubkey(key) 113 | cert.sign(key, "sha256") 114 | # 将密钥保存到临时文件中,确保最大的安全性 115 | file, path = tempfile.mkstemp() 116 | file = open(file, 'wb') 117 | file.write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert)) 118 | file.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, key)) 119 | file.close() 120 | return path 121 | 122 | 123 | def compact_ip(ip, appendix=''): 124 | return str(socket.inet_aton(ip).hex()) + appendix 125 | 126 | 127 | def get_ip() -> str: 128 | st = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 129 | try: 130 | st.connect(('10.255.255.255', 1)) 131 | ip = st.getsockname()[0] 132 | except OSError: 133 | ip = '127.0.0.1' 134 | finally: 135 | st.close() 136 | return ip 137 | 138 | 139 | def broadcast_to_all_interfaces(sk: socket.socket, content: bytes): 140 | interface_stats = psutil.net_if_stats() 141 | for interface, addresses in psutil.net_if_addrs().items(): 142 | if interface not in interface_stats or not interface_stats[interface].isup: 143 | continue 144 | for addr in addresses: 145 | if addr.family == socket.AF_INET and addr.netmask: 146 | broadcast_address = ipaddress.IPv4Network(f"{addr.address}/{addr.netmask}", 147 | strict=False).broadcast_address 148 | if broadcast_address.is_loopback: 149 | continue 150 | try: 151 | sk.sendto(content, (str(broadcast_address), config.signal_port)) 152 | except OSError: 153 | pass 154 | 155 | 156 | class ConfigOption(StrEnum): 157 | """ 158 | 配置文件的Option的枚举类 159 | name为配置项名称,value为配置的默认值 160 | """ 161 | section_Main = 'Main' 162 | windows_default_path = '~/Desktop' 163 | linux_default_path = '~/FileTransferTool/FileRecv' 164 | 165 | section_Log = 'Log' 166 | windows_log_dir = 'C:/ProgramData/logs' 167 | linux_log_dir = '~/FileTransferTool/logs' 168 | log_file_archive_count = '10' 169 | log_file_archive_size = '52428800' 170 | 171 | section_Port = 'Port' 172 | server_port = '2023' 173 | signal_port = '2022' 174 | 175 | @property 176 | def name_and_value(self): 177 | return self.name, self 178 | 179 | 180 | # 配置实体类 181 | @dataclass 182 | class Configration: 183 | default_path: Path 184 | log_dir: Path 185 | log_file_archive_count: int 186 | log_file_archive_size: int 187 | server_port: int 188 | signal_port: int 189 | 190 | 191 | # 配置文件相关 192 | class Config: 193 | config_file: Final[str] = '../config' 194 | 195 | @staticmethod 196 | def generate_config(): 197 | config_parser = ConfigParser() 198 | cur_section = '' 199 | for name, item in ConfigOption.__members__.items(): 200 | if name.startswith('section'): 201 | cur_section = item 202 | config_parser.add_section(cur_section) 203 | else: 204 | config_parser.set(cur_section, *item.name_and_value) 205 | try: 206 | with open(Config.config_file, 'w', encoding=utf8) as f: 207 | config_parser.write(f) 208 | except PermissionError as error: 209 | print_color(f'Failed to create the config file, {error}', level=LEVEL.ERROR, highlight=1) 210 | pause_before_exit(-1) 211 | 212 | @staticmethod 213 | def get_default_config(): 214 | default_path = Path( 215 | ConfigOption.windows_default_path if windows else ConfigOption.linux_default_path).expanduser() 216 | log_dir = Path(ConfigOption.windows_log_dir if windows else ConfigOption.linux_log_dir).expanduser() 217 | cur_folder = None 218 | try: 219 | if not default_path.exists(): 220 | cur_folder = default_path 221 | default_path.mkdir(parents=True) 222 | if not log_dir.exists(): 223 | cur_folder = log_dir 224 | log_dir.mkdir(parents=True) 225 | except OSError as error: 226 | print_color(f'Failed to create {cur_folder}, {error}', level=LEVEL.ERROR, highlight=1) 227 | pause_before_exit(-1) 228 | return Configration(default_path=default_path, log_dir=log_dir, 229 | log_file_archive_count=int(ConfigOption.log_file_archive_count), 230 | log_file_archive_size=int(ConfigOption.log_file_archive_size), 231 | server_port=int(ConfigOption.server_port), 232 | signal_port=int(ConfigOption.signal_port)) 233 | 234 | @staticmethod 235 | def load_config(): 236 | if not os.path.exists(Config.config_file): 237 | # Config.generate_config() 238 | return Config.get_default_config() 239 | cur_folder = None 240 | try: 241 | cnf = ConfigParser() 242 | cnf.read(Config.config_file, encoding=utf8) 243 | path_name = ConfigOption.windows_default_path.name if windows else ConfigOption.linux_default_path.name 244 | default_path = Path(cnf.get(ConfigOption.section_Main, path_name)).expanduser() 245 | if not default_path.exists(): 246 | cur_folder = default_path 247 | default_path.mkdir(parents=True) 248 | log_dir_name = (ConfigOption.windows_log_dir if windows else ConfigOption.linux_log_dir).name 249 | log_dir = Path(cnf.get(ConfigOption.section_Log, log_dir_name)).expanduser() 250 | if not log_dir.exists(): 251 | cur_folder = log_dir 252 | log_dir.mkdir(parents=True) 253 | log_file_archive_count = cnf.getint(ConfigOption.section_Log, ConfigOption.log_file_archive_count.name) 254 | log_file_archive_size = cnf.getint(ConfigOption.section_Log, ConfigOption.log_file_archive_size.name) 255 | server_port = cnf.getint(ConfigOption.section_Port, ConfigOption.server_port.name) 256 | signal_port = cnf.getint(ConfigOption.section_Port, ConfigOption.signal_port.name) 257 | except OSError as e: 258 | print_color(f'Failed to create {cur_folder}, {e}', level=LEVEL.ERROR, highlight=1) 259 | pause_before_exit(-1) 260 | except (NoOptionError, NoSectionError, ValueError, ParsingError) as e: 261 | print_color(f'Read configuration error, use default configuration: {e}', level=LEVEL.WARNING, highlight=1) 262 | return Config.get_default_config() 263 | else: 264 | return Configration(default_path=default_path, log_dir=log_dir, server_port=server_port, 265 | log_file_archive_count=log_file_archive_count, signal_port=signal_port, 266 | log_file_archive_size=log_file_archive_size) 267 | 268 | 269 | # 加载配置 270 | config: Final[Configration] = Config.load_config() 271 | -------------------------------------------------------------------------------- /src/ftt_sn.py: -------------------------------------------------------------------------------- 1 | import concurrent 2 | import os.path 3 | import shutil 4 | import signal 5 | from ftt_lib import * 6 | from concurrent.futures import ThreadPoolExecutor 7 | from ftt_base import FTTBase 8 | 9 | 10 | @dataclass 11 | class CopyFolderMeta: 12 | source: str 13 | target: str 14 | pbar: PbarManager 15 | large_files_info: deque[list] 16 | small_files_info: deque[list] 17 | finished_files: list[str] 18 | 19 | 20 | class FTTSn(FTTBase): 21 | def __init__(self, threads): 22 | super().__init__(threads, True) 23 | self.__meta: CopyFolderMeta = ... 24 | 25 | def _boot(self): 26 | self.logger.info('In Single Node Mode') 27 | self.logger.info(f'Current threads: {self.threads}') 28 | threading.Thread(name='ArchThread', target=self._compress_log_files, daemon=True).start() 29 | self.executor = concurrent.futures.ThreadPoolExecutor(max_workers=self.threads) 30 | 31 | def _shutdown(self): 32 | self.logger.close() 33 | self._history_file.close() 34 | if package: 35 | os.system('pause') 36 | os.kill(os.getpid(), signal.SIGINT) 37 | 38 | def __compare_folder(self, source: str, target: str): 39 | source_files_info = get_files_info_relative_to_basedir(source, 'source') 40 | target_files_info = get_files_info_relative_to_basedir(target, 'target') 41 | compare_result = compare_files_info(source_files_info, target_files_info) 42 | msgs = print_compare_result(source, target, compare_result) 43 | self.logger.silent_write(msgs) 44 | 45 | files_info_equal = compare_result[2] 46 | if not files_info_equal: 47 | return 48 | command = input("Continue to compare hash for filename and size both equal set?(y/n): ").lower() 49 | if command not in ('y', 'yes'): 50 | return 51 | source_results = FileHash.parallel_calc_hash(source, files_info_equal, True) 52 | target_results = FileHash.parallel_calc_hash(target, files_info_equal, True) 53 | hash_not_matching = [filename for filename in files_info_equal if 54 | source_results[filename] != target_results[filename]] 55 | msg = ["hash not matching: "] + [('\t' + file_name) for file_name in hash_not_matching] 56 | print('\n'.join(msg)) 57 | 58 | files_hash_equal = [filename for filename in files_info_equal if os.path.getsize( 59 | PurePath(source, filename)) >> SMALL_FILE_CHUNK_SIZE and filename not in hash_not_matching] 60 | if not files_hash_equal: 61 | return 62 | source_results = FileHash.parallel_calc_hash(source, files_info_equal, False) 63 | target_results = FileHash.parallel_calc_hash(target, files_info_equal, False) 64 | for filename in files_hash_equal: 65 | if source_results[filename] != target_results[filename]: 66 | print('\t' + filename) 67 | msg.append('\t' + filename) 68 | if len(msg) == 1: 69 | print('\t' + 'None') 70 | msg.append('\t' + 'None') 71 | msg.append('') 72 | self.logger.silent_write(['\n'.join(msg)]) 73 | 74 | def __prepare_to_send(self, source: str, target: str): 75 | source_folders, source_files = get_dir_file_name(source, desc_suffix='source') 76 | target_folders, target_files = get_dir_file_name(target, desc_suffix='target') if os.path.exists( 77 | target) else ({}, []) 78 | 79 | makedirs(self.logger, set(source_folders.keys()) - set(target_folders.keys()), target) 80 | # 接收对方已有的文件名并计算出对方没有的文件 81 | files = set(source_files) - set(target_files) 82 | self.logger.info(f"{len(source_files) - len(files)} files already exists in target") 83 | if not files: 84 | self.logger.info('No files to send', highlight=1) 85 | return None 86 | large_files_info, small_files_info, _, pbar = collect_files_info(self.logger, files, source) 87 | self.__meta = CopyFolderMeta(source, target, PbarManager(pbar), large_files_info, small_files_info, []) 88 | return files 89 | 90 | def __send_large_files(self, position: int): 91 | view = memoryview(buf := bytearray(4096)) 92 | while len(self.__meta.large_files_info): 93 | filename, file_size, time_info = self.__meta.large_files_info.pop() 94 | source_file = PurePath(self.__meta.source, filename) 95 | target_file = avoid_filename_duplication(str(PurePath(self.__meta.target, filename))) 96 | target_temp = f'{target_file}.ftsdownload' 97 | try: 98 | with open(source_file, 'rb') as sfp, open(target_temp, 'wb') as tfp: 99 | # 服务端已有的文件大小 100 | target_size = os.path.getsize(target_temp) 101 | sfp.seek(target_size, 0) 102 | pbar_width = get_terminal_size().columns / 4 103 | with tqdm(total=file_size - target_size, desc=shorten_path(filename, pbar_width), unit='bytes', 104 | unit_scale=True, mininterval=0.3, position=position, leave=False, disable=position == 0, 105 | unit_divisor=1024) as pbar: 106 | copied_size = 0 107 | while size := sfp.readinto(buf): 108 | tfp.write(view[:size]) 109 | copied_size += size 110 | # 4MB 111 | if copied_size >> 22: 112 | pbar.update(copied_size) 113 | self.__meta.pbar.update(copied_size) 114 | copied_size = 0 115 | pbar.update(copied_size) 116 | self.__meta.pbar.update(copied_size) 117 | os.rename(target_temp, target_file) 118 | shutil.copystat(source_file, target_file) 119 | self.__meta.pbar.update(target_size, decrease=True) 120 | self.__meta.finished_files.append(filename) 121 | except FileNotFoundError as e: 122 | self.logger.error(f'Failed to open: {e.filename}') 123 | continue 124 | except PermissionError as err: 125 | self.logger.warning(f'Failed to rename: {target_temp} -> {target_file}, {err}') 126 | except Exception as e: 127 | self.logger.error(f"Failed to copy large file: {e}", highlight=1) 128 | 129 | def __send_small_files(self, position: int): 130 | idx, files_info = 0, [] 131 | while len(self.__meta.small_files_info): 132 | try: 133 | total_size, num, files_info = self.__meta.small_files_info.pop() 134 | with tqdm(total=total_size, desc=f'{num} small files', unit='bytes', unit_scale=True, 135 | mininterval=0.2, position=position, leave=False, unit_divisor=1024) as pbar: 136 | for idx, (filename, file_size, _) in enumerate(files_info): 137 | shutil.copy2(os.path.join(self.__meta.source, filename), 138 | os.path.join(self.__meta.target, filename)) 139 | pbar.update(file_size) 140 | self.__meta.pbar.update(total_size) 141 | except Exception as e: 142 | self.logger.error(f"Failed to copy small files: {e}", highlight=1) 143 | finally: 144 | self.__meta.finished_files.extend([filename for filename, _, _ in files_info[:idx + 1]]) 145 | 146 | def __send_file(self, position: int): 147 | if position < 3: 148 | self.__send_large_files(position) 149 | self.__send_small_files(position) 150 | else: 151 | self.__send_small_files(position) 152 | self.__send_large_files(position) 153 | 154 | def __force_sync_folder(self, source, target): 155 | """ 156 | 强制将本地文件夹的内容同步到对方文件夹,同步后双方文件夹中的文件内容一致 157 | """ 158 | source_files_info = get_files_info_relative_to_basedir(source, 'source') 159 | target_files_info = get_files_info_relative_to_basedir(target, 'target') 160 | files_smaller_than_target, files_smaller_than_source, files_info_equal, files_not_exist_in_target, file_not_exists_in_source = compare_files_info( 161 | source_files_info, target_files_info) 162 | 163 | source_results = get_files_modified_time(source, files_info_equal, 'source') 164 | target_results = get_files_modified_time(target, files_info_equal, 'target') 165 | mtime_not_matching = [filename for filename in files_info_equal if 166 | int(source_results[filename]) != int(target_results[filename])] 167 | msgs = ['\n[INFO ] ' + get_log_msg( 168 | f'Force sync files: source folder {source} -> target folder {target}\n')] 169 | for arg in [("files exist in target but not in source: ", file_not_exists_in_source), 170 | ("files in source smaller than target: ", files_smaller_than_target), 171 | ("files in target smaller than source: ", files_smaller_than_source)]: 172 | msgs.append(print_filename_if_exists(*arg, print_if_empty=False)) 173 | msg = ["files modified time not matching: "] 174 | if mtime_not_matching: 175 | msg.extend([ 176 | f'\t{filename}: {format_timestamp(source_results[filename])} <-> {format_timestamp(target_results[filename])}' 177 | for filename in mtime_not_matching]) 178 | else: 179 | msg.append('\tNone') 180 | if mtime_not_matching: 181 | print('\n'.join(msg)) 182 | msg.append('') 183 | self.logger.silent_write(msgs) 184 | 185 | files_to_remove = files_smaller_than_target + files_smaller_than_source + file_not_exists_in_source + mtime_not_matching 186 | if len(files_to_remove) != 0: 187 | command = input( 188 | f"Continue to force sync files in source folder({source})\n" 189 | f" with above files removed in target folder?(y/n): ").lower() 190 | if command not in ('y', 'yes'): 191 | return 192 | self.logger.silent_write([print_filename_if_exists('Files to be removed:', files_to_remove, False)]) 193 | for file_rel_path in tqdm(files_to_remove, delay=0.1, desc='Removing files', unit='files', leave=False): 194 | try: 195 | send2trash.send2trash(PurePath(target, file_rel_path)) 196 | except Exception as e: 197 | self.logger.warning(f'Failed to remove {file_rel_path}, reason: {e}') 198 | self.__copy_folder(source, target) 199 | 200 | def __copy_folder(self, source: str, target: str): 201 | if not (files := self.__prepare_to_send(source, target)): 202 | return 203 | # 发送文件 204 | futures = [self.executor.submit(self.__send_file, position) for position in range(1, self.threads + 1)] 205 | for future in futures: 206 | while not future.done(): 207 | time.sleep(0.2) 208 | 209 | fails = files - set(self.__meta.finished_files) 210 | self.__meta.finished_files.clear() 211 | # 比对发送失败的文件 212 | self.__meta.pbar.set_status(len(fails) > 0) 213 | if fails: 214 | self.logger.error("Failed to copy: ", highlight=1) 215 | for fail in fails: 216 | self.logger.warning(fail) 217 | errors = [future.exception() for future in futures] 218 | if errors.count(None) != len(errors): 219 | errors = '\n'.join([f'Thread-{idx}: {exception}' for idx, exception in enumerate(errors) if exception]) 220 | self.logger.error(f"Exceptions occurred during this sending: \n{errors}", highlight=1) 221 | 222 | def execute(self, command): 223 | if command == sysinfo: 224 | print_sysinfo(get_sys_info()) 225 | elif command.startswith((compare, force_sync, cp)): 226 | cmd, source, target = parse_command(command) 227 | if not cmd: 228 | self.logger.warning('Invalid command') 229 | return 230 | 231 | if not os.path.isdir(source) or not os.path.exists(source): 232 | self.logger.warning('Source folder does not exist') 233 | return 234 | 235 | if (not os.path.isdir(target) or not os.path.exists(target)) and command.startswith((compare, force_sync)): 236 | self.logger.warning('Target folder does not exist') 237 | return 238 | 239 | if cmd == cp: 240 | self.__copy_folder(source, target) 241 | elif cmd == compare: 242 | self.__compare_folder(source, target) 243 | else: 244 | self.__force_sync_folder(source, target) 245 | 246 | elif command.startswith(history): 247 | print_history(int(command.split()[1])) if len(command.split()) > 1 and command.split()[ 248 | 1].isdigit() else print_history() 249 | else: 250 | self.logger.warning(f'Unknown command: {command}') 251 | 252 | def start(self): 253 | self._boot() 254 | 255 | try: 256 | while True: 257 | command = input('> ').strip() 258 | if not command: 259 | continue 260 | self._add_history(command) 261 | if command in ['q', 'quit', 'exit']: 262 | break 263 | self.execute(command) 264 | except Exception as e: 265 | self.logger.error(f"Error occurred: {e}", highlight=1) 266 | print(e) 267 | finally: 268 | self._shutdown() 269 | -------------------------------------------------------------------------------- /src/pbar_manager.py: -------------------------------------------------------------------------------- 1 | from tqdm import tqdm 2 | 3 | 4 | class PbarManager: 5 | def __init__(self, pbar: tqdm): 6 | self.__pbar = pbar 7 | self.__lock = self.__pbar.get_lock() 8 | 9 | def update(self, size: int, decrease=False): 10 | with self.__lock: 11 | if not decrease: 12 | self.__pbar.update(size) 13 | else: 14 | self.__pbar.total -= size 15 | 16 | def set_status(self, fail: bool): 17 | self.__pbar.colour = '#F44336' if fail else '#98c379' 18 | self.__pbar.close() 19 | -------------------------------------------------------------------------------- /src/sys_info.py: -------------------------------------------------------------------------------- 1 | import platform 2 | import time 3 | import psutil 4 | from utils import get_size, ThreadWithResult 5 | from constants import cpu_count, username 6 | 7 | 8 | def get_net_io(): 9 | net_io_before = psutil.net_io_counters() 10 | time.sleep(1) 11 | net_io_now = psutil.net_io_counters() 12 | return {"upload": get_size(net_io_now.bytes_sent - net_io_before.bytes_sent) + '/s', 13 | "download": get_size(net_io_now.bytes_recv - net_io_before.bytes_recv) + '/s'} 14 | 15 | 16 | def get_cpu_percent(): 17 | return f"{psutil.cpu_percent(interval=1)}%" 18 | 19 | 20 | def get_disk_io(): 21 | disk_io_before = psutil.disk_io_counters() 22 | time.sleep(1) 23 | disk_io_now = psutil.disk_io_counters() 24 | return {"read_count": f'{disk_io_now.read_count - disk_io_before.read_count} times/s', 25 | "write_count": f'{disk_io_now.write_count - disk_io_before.write_count} times/s', 26 | "read_bytes": f'{get_size(disk_io_now.read_bytes - disk_io_before.read_bytes)}/s', 27 | "write_bytes": f'{get_size(disk_io_now.write_bytes - disk_io_before.write_bytes)}/s'} 28 | 29 | 30 | def get_sys_info(): 31 | def format_time(time_second): 32 | minutes, seconds = divmod(time_second, 60) 33 | hours, minutes = divmod(minutes, 60) 34 | days, hours = divmod(hours, 24) 35 | return f"{days}d {hours}h {minutes}m {seconds}s" 36 | 37 | host = platform.node() 38 | # 系统的内存利用率 39 | memory = psutil.virtual_memory() 40 | used = get_size(memory.used) 41 | total = get_size(memory.total) 42 | memory_use_percent = str(memory.percent) + ' %' 43 | # 系统电池使用情况 44 | battery = psutil.sensors_battery() 45 | # 系统硬盘使用情况 46 | disks = [] 47 | for disk_partition in psutil.disk_partitions(): 48 | usage = psutil.disk_usage(disk_partition.mountpoint) 49 | disk_info = {'device': disk_partition.device.rstrip('\\'), 'fstype': disk_partition.fstype, 50 | 'total': get_size(usage.total), 'free': get_size(usage.free), 'percent': f'{usage.percent}%'} 51 | disks.append(disk_info) 52 | # 异步获取cpu、网络、硬盘的io 53 | threads = [ThreadWithResult(method) for method in (get_net_io, get_cpu_percent, get_disk_io)] 54 | for thread in threads: 55 | thread.start() 56 | net_io, cpu_percent, disk_io = [thread.get_result() for thread in threads] 57 | # 整合信息 58 | info = { 59 | "user": {"username": username, 'host': host}, 60 | "system": {"platform": platform.system(), "version": platform.version(), 61 | "architecture": platform.architecture()[0] 62 | }, 63 | "boot time": format_time(int(time.time() - psutil.boot_time())), 64 | "cpu": {"count": cpu_count, "logic_count": psutil.cpu_count(logical=True), 65 | "percentage": cpu_percent, 'info': platform.processor(), 66 | "manufacturer": platform.machine(), 67 | "frequency": f'{psutil.cpu_freq().current / 1000:.2f}Ghz'}, 68 | "memory": {"used": used, "total": total, "percentage": memory_use_percent}, 69 | "network": net_io, 70 | "disks": {'info': disks, 'io': disk_io} 71 | } 72 | battery_info = None 73 | if battery: 74 | if battery.secsleft == -1: 75 | secs_left = 'UNKNOWN' 76 | elif battery.secsleft == -2: 77 | secs_left = 'UNLIMITED' 78 | else: 79 | secs_left = format_time(battery.secsleft) 80 | battery_info = {"percent": battery.percent, "secsleft": secs_left, 81 | "power_plugged": "on" if battery.power_plugged else "off"} 82 | info.update({"battery": battery_info}) 83 | return info 84 | 85 | 86 | def print_sysinfo(info): 87 | user, system, cpu, memory, network, battery, disks, disks_io = info['user'], info['system'], info['cpu'], info[ 88 | 'memory'], info['network'], info['battery'], info['disks'], info['disks']['io'] 89 | diskinfo, blank = [], ' ' 90 | for disk in disks['info']: 91 | diskinfo.append( 92 | f"{blank}{disk['device']} available {disk['free']:9}, total {disk['total']:9}, used {disk['percent']}, type {disk['fstype']}") 93 | diskinfo = ('\n' + blank).join(diskinfo) 94 | battery_info = f"percent: {battery['percent']}%, power plugged: {battery['power_plugged']}, time left: {battery['secsleft']}" if battery else "Battery not detected" 95 | msg = f"""User: {user['username']}, host: {user['host']}: 96 | System : {system['platform']} {system['version']} {system['architecture']} 97 | Boot time: {info['boot time']} 98 | Processor: {cpu['percentage']}, {cpu['manufacturer']}, {cpu['count']} cores, {cpu['logic_count']} threads, {cpu['frequency']}, {cpu['info']} 99 | Memory : {memory['used']}/{memory['total']} usage: {memory['percentage']} 100 | Network : {network['download']}↓ {network['upload']}↑ 101 | Disk : read hit: {disks_io['read_count']}, write hit: {disks_io['write_count']}, read speed: {disks_io['read_bytes']}, write speed: {disks_io['write_bytes']} 102 | {diskinfo} 103 | Battery : {battery_info} 104 | """ 105 | print(msg) 106 | return msg 107 | 108 | 109 | if __name__ == '__main__': 110 | print_sysinfo(get_sys_info()) 111 | -------------------------------------------------------------------------------- /src/test/test_ftc.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import unittest 4 | from datetime import datetime 5 | from pathlib import Path 6 | from unittest.mock import patch 7 | from src.FTC import FTC 8 | from src.Utils import config 9 | from tools import create_random_file 10 | 11 | 12 | class FTCTest(unittest.TestCase): 13 | def setUp(self): 14 | self.test_dir = Path('ftt_test', 'ftc_test') 15 | self.fts_test_dir = os.path.normcase(Path('ftt_test', 'fts_test')) 16 | if not self.test_dir.exists(): 17 | os.makedirs(self.test_dir) 18 | self.signal_file = Path(self.test_dir, 'signal_test_file.txt').as_posix() 19 | create_random_file(self.signal_file) 20 | create_random_file(Path(self.test_dir, 'ftc_file.txt')) 21 | create_random_file(Path(self.test_dir, 'smaller_file.txt'), file_size=1024 * 6) 22 | create_random_file(Path(self.test_dir, 'larger_file.txt'), file_size=1024 * 60) 23 | create_random_file(Path(self.test_dir, 'hash_unmatch_file.txt'), file_size=1024 * 256) 24 | self.batch_send_dir = Path(self.test_dir, 'batch_send_test').as_posix() 25 | if not os.path.exists(self.batch_send_dir): 26 | os.makedirs(self.batch_send_dir) 27 | for i in range(15): 28 | create_random_file(Path(self.batch_send_dir, f'test_file_{i}.txt')) 29 | 30 | @patch('builtins.input') 31 | def test_ftc(self, mock_input): 32 | mock_input.side_effect = ['pwd', 'get clipboard', 'send clipboard', self.signal_file, 33 | self.batch_send_dir, 'sysinfo', f'compare {self.test_dir} {self.fts_test_dir}', 'y', 34 | self.signal_file, self.batch_send_dir, 'speedtest 50', 'history 15', 35 | 'q'] 36 | with self.assertWarns((ResourceWarning, DeprecationWarning)): 37 | FTC(threads=6, host='127.0.0.1', password='test').start() 38 | 39 | def tearDown(self): 40 | fts_signal_file = Path(self.fts_test_dir, 'signal_test_file.txt') 41 | try: 42 | self.assertEqual(int(os.path.getctime(self.signal_file)), int(os.path.getctime(fts_signal_file))) 43 | self.assertEqual(int(os.path.getmtime(self.signal_file)), int(os.path.getmtime(fts_signal_file))) 44 | finally: 45 | os.remove('config') 46 | shutil.rmtree(os.path.dirname(self.test_dir)) 47 | os.startfile(Path(config.log_dir, f'{datetime.now():%Y_%m_%d}_client.log')) 48 | os.startfile(Path(config.log_dir, f'{datetime.now():%Y_%m_%d}_server.log')) 49 | 50 | 51 | if __name__ == '__main__': 52 | unittest.main() 53 | -------------------------------------------------------------------------------- /src/test/test_fts.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import unittest 3 | from pathlib import Path 4 | 5 | from src.FTS import FTS 6 | from tools import create_random_file 7 | 8 | 9 | class FTSTest(unittest.TestCase): 10 | def setUp(self): 11 | self.test_dir = Path('ftt_test', 'fts_test') 12 | if not self.test_dir.exists(): 13 | os.makedirs(self.test_dir) 14 | create_random_file(Path(self.test_dir, 'fts_file.txt')) 15 | create_random_file(Path(self.test_dir, 'smaller_file.txt'), file_size=1024 * 60) 16 | create_random_file(Path(self.test_dir, 'larger_file.txt'), file_size=1024 * 6) 17 | create_random_file(Path(self.test_dir, 'hash_unmatch_file.txt'), file_size=1024 * 256) 18 | 19 | def test_fts(self): 20 | with self.assertWarns((ResourceWarning, DeprecationWarning)): 21 | fts = FTS(base_dir=self.test_dir, password='test') 22 | fts.start() 23 | 24 | 25 | if __name__ == '__main__': 26 | unittest.main() 27 | -------------------------------------------------------------------------------- /src/test/tools.py: -------------------------------------------------------------------------------- 1 | import os 2 | import random 3 | 4 | min_size = 1024 * 5 5 | max_size = 1024 * 500 6 | 7 | 8 | def create_random_file(file_path, file_size=0): 9 | # 生成随机文件大小 10 | if file_size == 0: 11 | file_size = random.randint(min_size, max_size) 12 | 13 | # 生成随机数据并写入文件 14 | with open(file_path, 'wb') as file: 15 | random_data = os.urandom(file_size) 16 | file.write(random_data) 17 | -------------------------------------------------------------------------------- /src/utils.py: -------------------------------------------------------------------------------- 1 | import lzma 2 | import re 3 | import os 4 | import pickle 5 | import random 6 | import socket 7 | import threading 8 | import time 9 | from collections import deque 10 | from concurrent.futures import ThreadPoolExecutor, as_completed 11 | from hashlib import md5 12 | from os import PathLike 13 | from pathlib import PurePath, Path 14 | from datetime import datetime 15 | from typing import TextIO 16 | from tqdm import tqdm 17 | import pyperclip 18 | from constants import * 19 | 20 | # 解决win10的cmd中直接使用转义序列失效问题 21 | if windows: 22 | os.system("") 23 | 24 | 25 | # 日志类,简化日志打印 26 | class Logger: 27 | def __init__(self, log_file_path: PurePath): 28 | self.__log_file: TextIO = open(log_file_path, 'a', encoding=utf8) 29 | self.__log_lock: threading.Lock = threading.Lock() 30 | self.__writing_lock: threading.Lock = threading.Lock() 31 | self.__writing_buffer: list[str] = [] 32 | threading.Thread(target=self.auto_flush, daemon=True).start() 33 | 34 | def log(self, msg, level: LEVEL = LEVEL.LOG, highlight=0): 35 | msg = get_log_msg(msg) 36 | with self.__log_lock: 37 | print(f"\r\033[{highlight}{level}m{msg}\033[0m") 38 | with self.__writing_lock: 39 | self.__writing_buffer.append(f'[{level.name:7}] {msg}\n') 40 | 41 | def info(self, msg, highlight=0): 42 | self.log(msg, LEVEL.INFO, highlight) 43 | 44 | def warning(self, msg, highlight=0): 45 | self.log(msg, LEVEL.WARNING, highlight) 46 | 47 | def error(self, msg, highlight=0): 48 | self.log(msg, LEVEL.ERROR, highlight) 49 | 50 | def success(self, msg, highlight=0): 51 | self.log(msg, LEVEL.SUCCESS, highlight) 52 | 53 | def flush(self): 54 | if self.__writing_buffer: 55 | with self.__writing_lock: 56 | msgs, self.__writing_buffer = self.__writing_buffer, [] 57 | self.__log_file.writelines(msgs) 58 | msgs.clear() 59 | self.__log_file.flush() 60 | 61 | def auto_flush(self): 62 | while True: 63 | self.flush() 64 | time.sleep(1) 65 | 66 | def silent_write(self, msgs: list): 67 | with self.__writing_lock: 68 | self.__writing_buffer.extend(msgs) 69 | 70 | def close(self): 71 | if self.__log_file.closed: 72 | return 73 | self.flush() 74 | self.__log_file.close() 75 | 76 | 77 | class ConnectionDisappearedError(ConnectionError): 78 | pass 79 | 80 | 81 | class ESocket: 82 | MAX_BUFFER_SIZE = 4096 83 | 84 | def __init__(self, conn: socket.socket): 85 | if conn is None: 86 | raise ValueError('Connection Can Not Be None') 87 | self.__conn: socket.socket = conn 88 | self.__buf = bytearray(self.MAX_BUFFER_SIZE) 89 | self.__view = memoryview(self.__buf) 90 | 91 | def sendall(self, data): 92 | self.__conn.sendall(data) 93 | 94 | def sendfile(self, file, offset=0, count=None): 95 | return self.__conn.sendfile(file, offset, count) 96 | 97 | def send_size(self, size: int): 98 | self.__conn.sendall(size_struct.pack(size)) 99 | 100 | def recv(self, size=MAX_BUFFER_SIZE): 101 | size = self.__conn.recv_into(self.__buf, size) 102 | if size == 0: 103 | raise ConnectionDisappearedError('Connection Disappeared') 104 | return self.__view[:size], size 105 | 106 | def getpeername(self): 107 | return self.__conn.getpeername() 108 | 109 | def close(self): 110 | self.__conn.shutdown(socket.SHUT_RDWR) 111 | self.__conn.close() 112 | 113 | def settimeout(self, value: float | None): 114 | self.__conn.settimeout(value) 115 | 116 | def recv_data(self, size: int): 117 | # 避免粘包 118 | result = bytearray() 119 | while size: 120 | data, recv_size = self.recv(min(self.MAX_BUFFER_SIZE, size)) 121 | result += data 122 | size -= recv_size 123 | return result 124 | 125 | def recv_size(self) -> int: 126 | return size_struct.unpack(self.recv_data(size_struct.size))[0] 127 | 128 | def send_data_with_size(self, data: bytes): 129 | self.send_size(len(data)) 130 | self.__conn.sendall(data) 131 | 132 | def send_with_compress(self, data): 133 | self.send_data_with_size(lzma.compress(pickle.dumps(data, protocol=pickle.HIGHEST_PROTOCOL), preset=9)) 134 | 135 | def recv_with_decompress(self): 136 | return pickle.loads(lzma.decompress(self.recv_data(self.recv_size()))) 137 | 138 | def recv_head(self) -> tuple[str, str, int]: 139 | """ 140 | 接收文件头 141 | @return: 文件(夹)名等,命令,文件大小 142 | """ 143 | command, data_size, name_size = head_struct.unpack(self.recv_data(head_struct.size)) 144 | filename = self.recv_data(name_size).decode(utf8) if name_size else '' 145 | return filename, command, data_size 146 | 147 | def send_head(self, name: str, command: int, size: int): 148 | """ 149 | 打包文件头 14字节的命令类型 + 8字节的文件大小 + 2字节的文件夹名长度 + 文件夹名 150 | @param name: 文件(夹)名等 151 | @param command: 命令 152 | @param size: 文件大小 153 | @return: 打包后的文件头 154 | """ 155 | length = len(name := name.encode(utf8)) 156 | self.__conn.sendall(head_struct.pack(command, size, length)) 157 | self.__conn.sendall(name) 158 | 159 | # def __getattr__(self, name): 160 | # return getattr(self.__conn, name) 161 | 162 | 163 | class ThreadWithResult(threading.Thread): 164 | def __init__(self, func, args=()): 165 | super(ThreadWithResult, self).__init__() 166 | self.func = func 167 | self.args = args 168 | self.result = None 169 | 170 | def run(self): 171 | self.result = self.func(*self.args) 172 | 173 | def get_result(self): 174 | self.join() 175 | return self.result 176 | 177 | 178 | def send_clipboard(conn: ESocket, logger: Logger, ftc=True): 179 | # 读取并编码剪切板的内容 180 | try: 181 | content = pyperclip.paste() 182 | except pyperclip.PyperclipException as e: 183 | logger.error(f'Get clipboard error: {e}') 184 | if not ftc: 185 | conn.send_head('', COMMAND.NULL, 0) 186 | return 187 | content_length = len(content.encode(utf8)) 188 | if content_length == 0 or content_length > 65535: 189 | if content_length == 0: 190 | logger.warning(f'Clipboard is empty') 191 | else: 192 | logger.warning(f'Clipboard is too large({get_size(content_length)}) to send.') 193 | if not ftc: 194 | conn.send_head('', COMMAND.NULL, content_length) 195 | return 196 | # logger.info(f'Send clipboard, size: {get_size(content_length)}') 197 | conn.send_head(content, COMMAND.PUSH_CLIPBOARD, content_length) 198 | 199 | 200 | def get_clipboard(conn: ESocket, logger: Logger, content=None, command=None, length=None, ftc=True): 201 | # 获取对方剪切板的内容 202 | if ftc: 203 | conn.send_head('', COMMAND.PULL_CLIPBOARD, 0) 204 | content, command, length = conn.recv_head() 205 | if command != COMMAND.PUSH_CLIPBOARD: 206 | logger.warning(f"Can not get content in clipboard for some reason.") 207 | return 208 | 209 | logger.log(f"Get clipboard, size: {get_size(length)}\n{content}") 210 | # 拷贝到剪切板 211 | try: 212 | pyperclip.copy(content) 213 | except pyperclip.PyperclipException as e: 214 | logger.error(f'Copy content into clipboard error: {e}') 215 | print(f"Content: \n{content}") 216 | 217 | 218 | def print_color(msg, level: LEVEL = LEVEL.LOG, highlight=0): 219 | print(f"\r\033[{highlight}{level}m{msg}\033[0m") 220 | 221 | 222 | def get_log_msg(msg): 223 | now = datetime.now().strftime('%H:%M:%S.%f')[:-3] 224 | return f'{now} {threading.current_thread().name:12} {msg}' 225 | 226 | 227 | def get_size(size, factor=1024, suffix="B"): 228 | """ 229 | Scale bytes to its proper format 230 | e.g: 231 | 1253656 => '1.20MB' 232 | 1253656678 => '1.17GB' 233 | """ 234 | for data_unit in ["", "K", "M", "G", "T", "P"]: 235 | if size < factor: 236 | return f"{size:.2f}{data_unit}{suffix}" 237 | size /= factor 238 | 239 | 240 | def get_files_info_relative_to_basedir(base_dir: str, desc_suffix='files', position=0) -> dict[str, int]: 241 | """ 242 | 获取某个目录下所有文件的相对路径和文件大小,并显示进度条 243 | """ 244 | result = {} 245 | root_abs_path = os.path.abspath(base_dir) 246 | queue = deque([(root_abs_path, '.')]) 247 | processed_paths = set() 248 | folders = 0 249 | total_size = 0 250 | 251 | # 初始化进度显示 252 | pbar = tqdm(desc=f"Collecting {desc_suffix}", unit=" files", position=position, dynamic_ncols=True) 253 | while queue: 254 | current_abs_path, current_rel_path = queue.popleft() 255 | if current_abs_path in processed_paths: 256 | continue 257 | processed_paths.add(current_abs_path) 258 | folders += 1 259 | try: 260 | with os.scandir(current_abs_path) as it: 261 | for entry in it: 262 | entry_rel_path = f"{current_rel_path}/{entry.name}" if current_rel_path != '.' else entry.name 263 | if entry.is_dir(follow_symlinks=False): 264 | queue.append((entry.path, entry_rel_path)) 265 | elif entry.is_file(follow_symlinks=False): 266 | size = entry.stat().st_size 267 | result[entry_rel_path] = size 268 | total_size += size 269 | pbar.update(1) 270 | except PermissionError: 271 | continue 272 | 273 | # 更新进度条描述 274 | pbar.set_postfix(folders=folders, size=get_size(total_size)) 275 | pbar.close() 276 | return result 277 | 278 | 279 | def format_time(time_interval): 280 | if time_interval < 60: 281 | return f"{time_interval:.2f}".rstrip("0").rstrip(".") + 's' 282 | units = [(86400, 'd'), (3600, 'h'), (60, 'm'), (1, 's')] 283 | formatted_time = '' 284 | for unit_time, unit_label in units: 285 | if time_interval >= unit_time: 286 | unit_count, time_interval = divmod(time_interval, unit_time) 287 | formatted_time += f"{int(unit_count)}{unit_label}" 288 | return formatted_time if formatted_time else '0s' 289 | 290 | 291 | def parse_command(command: str) -> tuple[None, None, None] | tuple[str, str, str]: 292 | """ 293 | 解析形如 'command "source" "target"' 或 'command source target' 的命令字符串。 294 | """ 295 | pattern = r''' 296 | ^\s*(\w+)\s+ # 命令 297 | (?: 298 | "([^"]+)" # source - 带双引号 299 | | 300 | ([^\s"]+) # source - 不带引号,不能包含空格或引号 301 | )\s+ 302 | (?: 303 | "([^"]+)" # target - 带双引号 304 | | 305 | ([^\s"]+) # target - 不带引号 306 | )\s*$ 307 | ''' 308 | match = re.match(pattern, command, re.VERBOSE) 309 | if not match: 310 | return None, None, None 311 | 312 | cmd = match.group(1) 313 | source = match.group(2) if match.group(2) is not None else match.group(3) 314 | target = match.group(4) if match.group(4) is not None else match.group(5) 315 | return cmd, source, target 316 | 317 | 318 | def compare_files_info(source_files_info, target_files_info): 319 | """ 320 | 获取两个文件信息字典的差异 321 | """ 322 | files_smaller_than_target, files_smaller_than_source, files_info_equal, files_not_exist_in_target = [], [], [], [] 323 | for filename in source_files_info.keys(): 324 | target_size = target_files_info.pop(filename, -1) 325 | if target_size == -1: 326 | files_not_exist_in_target.append(filename) 327 | continue 328 | size_diff = source_files_info[filename] - target_size 329 | if size_diff < 0: 330 | files_smaller_than_target.append(filename) 331 | elif size_diff == 0: 332 | files_info_equal.append(filename) 333 | else: 334 | files_smaller_than_source.append(filename) 335 | file_not_exists_in_source = list(target_files_info.keys()) 336 | return files_smaller_than_target, files_smaller_than_source, files_info_equal, files_not_exist_in_target, file_not_exists_in_source 337 | 338 | 339 | def print_compare_result(source: str, target: str, compare_result: tuple): 340 | files_smaller_than_target, files_smaller_than_source, files_info_equal, files_not_exist_in_target, file_not_exists_in_source = compare_result 341 | simplified_info = files_info_equal[:10] + ['(more hidden...)'] if len( 342 | files_info_equal) > 10 else files_info_equal 343 | msgs = ['\n[INFO ] ' + get_log_msg( 344 | f'Compare the differences between source folder {source} and target folder {target}\n')] 345 | for arg in [("files exist in target but not in source: ", file_not_exists_in_source), 346 | ("files exist in source but not in target: ", files_not_exist_in_target), 347 | ("files in source smaller than target: ", files_smaller_than_target), 348 | ("files in target smaller than source: ", files_smaller_than_source), 349 | ("files name and size both equal in two sides: ", simplified_info)]: 350 | msgs.append(print_filename_if_exists(*arg)) 351 | return msgs 352 | 353 | 354 | def show_bandwidth(msg, data_size, interval, logger: Logger, level=LEVEL.SUCCESS): 355 | avg_bandwidth = get_size((data_size * 8 / interval) if interval != 0 else 0, factor=1000, suffix='bps') 356 | logger.log(f"{msg}, average bandwidth {avg_bandwidth}, takes {format_time(interval)}", level) 357 | 358 | 359 | if windows: 360 | from win_set_time import set_times 361 | 362 | 363 | def modify_file_time(logger: Logger, file_path: str, create_timestamp: float, modify_timestamp: float, 364 | access_timestamp: float): 365 | """ 366 | 用来修改文件的相关时间属性 367 | :param logger: 日志对象 368 | :param file_path: 文件路径名 369 | :param create_timestamp: 创建时间戳 370 | :param modify_timestamp: 修改时间戳 371 | :param access_timestamp: 访问时间戳 372 | """ 373 | try: 374 | if windows: 375 | set_times(file_path, create_timestamp, modify_timestamp, access_timestamp) 376 | else: 377 | os.utime(path=file_path, times=(access_timestamp, modify_timestamp)) 378 | except Exception as error: 379 | logger.warning(f'{file_path} file time modification failed, {error}') 380 | 381 | 382 | def makedirs(logger: Logger, dir_names, base_dir: str | PathLike): 383 | for dir_name in tqdm(dir_names, unit='folders', mininterval=0.1, delay=0.1, desc='Creating folders', leave=False): 384 | cur_dir = Path(base_dir, dir_name) 385 | if cur_dir.exists(): 386 | continue 387 | try: 388 | cur_dir.mkdir(parents=True) 389 | except FileNotFoundError: 390 | logger.error(f'Failed to create folder {cur_dir}', highlight=1) 391 | 392 | 393 | def pause_before_exit(exit_code=0): 394 | if package: 395 | os.system('pause') 396 | sys.exit(exit_code) 397 | 398 | 399 | def get_files_modified_time(base_folder, file_rel_paths: list[str], desc: str = 'files') -> dict[str, float]: 400 | results = {} 401 | for file_rel_path in tqdm(file_rel_paths, unit='files', mininterval=0.2, desc=f'Get {desc} modified time', 402 | leave=False): 403 | file_path = PurePath(base_folder, file_rel_path) 404 | results[file_rel_path] = os.path.getmtime(file_path) 405 | return results 406 | 407 | 408 | def format_timestamp(timestamp: float): 409 | return time.strftime(TIME_FORMAT, time.localtime(timestamp)) 410 | 411 | 412 | class FileHash: 413 | @staticmethod 414 | def _file_digest(file, file_md5, remained_size): 415 | """ 416 | 计算文件的MD5值 417 | 418 | @param remained_size: 文件剩余需要读取的大小 419 | @return: 420 | """ 421 | if remained_size >> SMALL_FILE_CHUNK_SIZE: 422 | buf = bytearray(buf_size) 423 | view = memoryview(buf) 424 | while size := file.readinto(buf): 425 | file_md5.update(view[:size]) 426 | else: 427 | file_md5.update(file.read()) 428 | return file_md5.hexdigest() 429 | 430 | @staticmethod 431 | def full_digest(filename): 432 | with open(filename, 'rb') as fp: 433 | return FileHash._file_digest(fp, md5(), os.path.getsize(filename)) 434 | 435 | @staticmethod 436 | def fast_digest(filename): 437 | file_size = os.path.getsize(filename) 438 | with open(filename, 'rb') as fp: 439 | file_md5 = md5() 440 | if file_size >> SMALL_FILE_CHUNK_SIZE: 441 | tiny_buf = bytearray(32 * KB) 442 | tiny_view = memoryview(tiny_buf) 443 | tail = file_size - FILE_TAIL_SIZE 444 | # Large file, read in chunks and include tail 445 | for offset in range(48): 446 | fp.seek(offset * (tail // 48)) 447 | size = fp.readinto(tiny_buf) 448 | file_md5.update(tiny_view[:size]) 449 | # Read the tail of the file 450 | fp.seek(tail) 451 | return FileHash._file_digest(fp, file_md5, FILE_TAIL_SIZE) 452 | return FileHash._file_digest(fp, file_md5, file_size) 453 | 454 | @staticmethod 455 | def parallel_calc_hash(base_folder, file_rel_paths: list[str], is_fast: bool): 456 | digest_func = FileHash.fast_digest if is_fast else FileHash.full_digest 457 | file_rel_paths = file_rel_paths.copy() 458 | random.shuffle(file_rel_paths) 459 | results = {} 460 | with ThreadPoolExecutor(max_workers=cpu_count) as executor: 461 | future_to_file = {executor.submit(digest_func, PurePath(base_folder, rel_path)): rel_path for rel_path in 462 | file_rel_paths} 463 | for future in tqdm(as_completed(future_to_file), total=len(file_rel_paths), unit='files', mininterval=0.2, 464 | desc=f'{"fast" if is_fast else "full"} hash calc', leave=False): 465 | filename = future_to_file[future] 466 | digest_value = future.result() 467 | results[filename] = digest_value 468 | return results 469 | 470 | 471 | def shorten_path(path: str, max_width: float) -> str: 472 | return path[:int((max_width - 3) / 3)] + '...' + path[-2 * int((max_width - 3) / 3):] if len( 473 | path) > max_width else path + ' ' * (int(max_width) - len(path)) 474 | 475 | 476 | def print_filename_if_exists(prompt, filename_list, print_if_empty=True): 477 | msg = [prompt] 478 | if filename_list: 479 | msg.extend([('\t' + file_name) for file_name in filename_list]) 480 | else: 481 | msg.append('\tNone') 482 | if filename_list or print_if_empty: 483 | print('\n'.join(msg)) 484 | msg.append('') 485 | return '\n'.join(msg) 486 | 487 | 488 | if __name__ == '__main__': 489 | print(get_files_info_relative_to_basedir(input('>>> '))) 490 | -------------------------------------------------------------------------------- /src/win_set_time.py: -------------------------------------------------------------------------------- 1 | # Reference to https://github.com/Delgan/win32-setctime 2 | from ctypes import byref, get_last_error, wintypes, WinDLL, WinError 3 | 4 | kernel32 = WinDLL("kernel32", use_last_error=True) 5 | 6 | CreateFileW = kernel32.CreateFileW 7 | SetFileTime = kernel32.SetFileTime 8 | CloseHandle = kernel32.CloseHandle 9 | 10 | CreateFileW.argtypes = ( 11 | wintypes.LPWSTR, 12 | wintypes.DWORD, 13 | wintypes.DWORD, 14 | wintypes.LPVOID, 15 | wintypes.DWORD, 16 | wintypes.DWORD, 17 | wintypes.HANDLE, 18 | ) 19 | CreateFileW.restype = wintypes.HANDLE 20 | 21 | SetFileTime.argtypes = ( 22 | wintypes.HANDLE, 23 | wintypes.PFILETIME, 24 | wintypes.PFILETIME, 25 | wintypes.PFILETIME, 26 | ) 27 | SetFileTime.restype = wintypes.BOOL 28 | 29 | CloseHandle.argtypes = (wintypes.HANDLE,) 30 | CloseHandle.restype = wintypes.BOOL 31 | VOID_HANDLE = wintypes.HANDLE(-1).value 32 | 33 | 34 | def from_timestamp(timestamp: float): 35 | timestamp = int(timestamp * 10000000) + 116444736000000000 36 | return byref(wintypes.FILETIME(timestamp, timestamp >> 32)) 37 | 38 | 39 | def set_times(filepath: str, create_timestamp: float, modify_timestamp: float, access_timestamp: float): 40 | handle = wintypes.HANDLE(CreateFileW(filepath, 256, 0, None, 3, 33554560, None)) 41 | 42 | if handle.value == VOID_HANDLE: 43 | raise WinError(get_last_error()) 44 | 45 | c_time = from_timestamp(create_timestamp) 46 | m_time = from_timestamp(modify_timestamp) 47 | a_time = from_timestamp(access_timestamp) 48 | 49 | if not wintypes.BOOL(SetFileTime(handle, c_time, a_time, m_time)): 50 | raise WinError(get_last_error()) 51 | 52 | if not wintypes.BOOL(CloseHandle(handle)): 53 | raise WinError(get_last_error()) 54 | --------------------------------------------------------------------------------