├── .github └── workflows │ ├── build-appimage.yml │ └── build.yml ├── .gitignore ├── LICENSE ├── README.md ├── assets ├── app.png ├── for-readme │ ├── home.png │ └── settings.png └── icons │ └── app.ico ├── core ├── __init__.py ├── config │ └── config_manager.py ├── ssh │ ├── __init__.py │ ├── file_transfer.py │ └── ssh_client.py └── sync │ ├── __init__.py │ └── sync_manager.py ├── main.py ├── requirements.txt ├── themes └── ui.qss ├── ui ├── __init__.py ├── widgets │ ├── __init__.py │ ├── file_list_widget.py │ ├── settings_dialog.py │ └── tray_icon.py └── windows │ ├── __init__.py │ └── main_window.py └── utils ├── __init__.py ├── config.py └── logger.py /.github/workflows/build-appimage.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release Linux App 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | build-linux: 13 | runs-on: ubuntu-22.04 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v3 18 | 19 | - name: Set up Python 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: '3.11' 23 | 24 | - name: Install system dependencies 25 | run: | 26 | sudo apt-get update 27 | sudo apt-get install -y \ 28 | libxcb-keysyms1 \ 29 | libxcb-render-util0 \ 30 | libxkbcommon-x11-0 \ 31 | libxcb-xinerama0 \ 32 | libxcb-image0 \ 33 | libxcb-xkb1 \ 34 | libxcb-shape0 \ 35 | libxcb-icccm4 \ 36 | appstream \ 37 | libfuse2 38 | 39 | - name: Install Python dependencies 40 | run: | 41 | pip install -r requirements.txt 42 | pip install pyinstaller 43 | 44 | - name: Build Linux App 45 | run: | 46 | pyinstaller --noconfirm --onefile --windowed --name GOSync \ 47 | --hidden-import "PySide6.QtCore" \ 48 | --hidden-import "PySide6.QtGui" \ 49 | --hidden-import "PySide6.QtWidgets" \ 50 | --hidden-import "paramiko" \ 51 | --hidden-import "cryptography" \ 52 | --hidden-import "scp" \ 53 | --add-data "assets:assets" \ 54 | --add-data "themes/ui.qss:themes" \ 55 | --add-data "ui:ui" \ 56 | --add-data "core:core" \ 57 | --add-data "utils:utils" \ 58 | main.py 59 | 60 | mkdir -p AppDir/usr/bin 61 | mkdir -p AppDir/usr/share/applications 62 | mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps 63 | 64 | cp dist/GOSync AppDir/usr/bin/GOSync 65 | cp assets/app.png AppDir/usr/share/icons/hicolor/256x256/apps/GOSync.png 66 | cp assets/app.png AppDir/GOSync.png 67 | 68 | echo "[Desktop Entry]" > AppDir/GOSync.desktop 69 | echo "Name=GOSync" >> AppDir/GOSync.desktop 70 | echo "Exec=GOSync" >> AppDir/GOSync.desktop 71 | echo "Icon=GOSync" >> AppDir/GOSync.desktop 72 | echo "Type=Application" >> AppDir/GOSync.desktop 73 | echo "Categories=Utility;Network;" >> AppDir/GOSync.desktop 74 | echo "Comment=Secure File Backup and Sync Application" >> AppDir/GOSync.desktop 75 | 76 | echo '#!/bin/sh' > AppDir/AppRun 77 | echo 'exec "$APPDIR/usr/bin/GOSync" "$@"' >> AppDir/AppRun 78 | chmod +x AppDir/AppRun 79 | 80 | - name: Create AppImage 81 | run: | 82 | curl -L https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage -o appimagetool 83 | chmod +x appimagetool 84 | ./appimagetool AppDir 85 | mkdir -p dist 86 | mv GOSync-x86_64.AppImage dist/ 87 | 88 | - name: Upload to GitHub Releases 89 | uses: softprops/action-gh-release@v1 90 | with: 91 | files: 'dist/GOSync-x86_64.AppImage' 92 | env: 93 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release GOSync 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | contents: write 10 | issues: write 11 | pull-requests: write 12 | 13 | jobs: 14 | build: 15 | runs-on: windows-latest 16 | 17 | steps: 18 | - name: Checkout repo 19 | uses: actions/checkout@v3 20 | 21 | - name: Set up Python 22 | uses: actions/setup-python@v4 23 | with: 24 | python-version: '3.8' 25 | 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install -r requirements.txt 30 | pip install pyinstaller 31 | 32 | - name: Build GOSync EXE 33 | run: | 34 | pyinstaller --noconfirm --onefile --windowed --name GOSync ` 35 | --hidden-import "PySide6.QtCore" ` 36 | --hidden-import "PySide6.QtGui" ` 37 | --hidden-import "PySide6.QtWidgets" ` 38 | --hidden-import "paramiko" ` 39 | --hidden-import "cryptography" ` 40 | --hidden-import "scp" ` 41 | --icon=assets/icons/app.ico ` 42 | --add-data "assets;assets" ` 43 | --add-data "themes/ui.qss;themes" ` 44 | --add-data "ui;ui" ` 45 | --add-data "core;core" ` 46 | --add-data "utils;utils" ` 47 | main.py 48 | 49 | - name: Create Release Archive 50 | run: | 51 | Compress-Archive -Path "dist/GOSync.exe", "LICENSE", "README.md" -DestinationPath "GOSync-${{ github.ref_name }}-windows.zip" 52 | shell: pwsh 53 | 54 | - name: Upload to GitHub Releases 55 | uses: softprops/action-gh-release@v1 56 | with: 57 | files: GOSync-${{ github.ref_name }}-windows.zip 58 | body: | 59 | GOSync ${{ github.ref_name }} Windows Release 60 | 61 | ## System Requirements 62 | - Windows 63 | - No additional Python installation needed (standalone executable) 64 | 65 | ## Features 66 | - Secure SSH file backup and sync 67 | - Encrypted settings storage 68 | - Modern UI with system tray support 69 | draft: false 70 | prerelease: false 71 | env: 72 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc 3 | *.pyo 4 | *.pyd 5 | *.pyw 6 | *.pyz 7 | 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2025 toxi360 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | GOSync Logo 3 |

4 | 5 |
6 | 7 | [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) 8 | [![Python](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org) 9 | [![Platform](https://img.shields.io/badge/platform-Windows%20|%20Linux-lightgrey.svg)](https://github.com/yourusername/GOSync) 10 | [![PySide6](https://img.shields.io/badge/GUI-PySide6-brightgreen.svg)](https://wiki.qt.io/Qt_for_Python) 11 | [![Security](https://img.shields.io/badge/security-SSH%20|%20SCP-green.svg)](https://github.com/yourusername/GOSync) 12 | [![Status](https://img.shields.io/badge/status-active-success.svg)](https://github.com/yourusername/GOSync) 13 | 14 |
15 | 16 | # GOSync - Secure File Backup Application 17 | 18 | GOSync is a desktop application that enables secure file backup and synchronization over SSH. 19 | 20 | ## ✨ Key Features 21 | 22 | ### 🔒 Security 23 | - **SSH key-based authentication support** 24 | - **Password-based authentication option** 25 | - **Encrypted configuration storage** 26 | - **Secure file transfer over SSH/SCP** 27 | 28 | ### 🔄 Synchronization 29 | - 🕒 **Automatic sync with 10-second interval** 30 | - 🚀 **Manual "Sync Now" option** 31 | - 📊 **Real-time progress tracking** 32 | - 🔍 **Smart file change detection** 33 | 34 | ### 📁 File Management 35 | - 🖱️ **Drag & drop file support** 36 | - 📥 **Download files from server** 37 | - 📤 **Upload files to server** 38 | - 🗑️ **Delete local files** 39 | - 🔄 **Refresh file lists** 40 | 41 | ### 💻 User Interface 42 | - 🎨 **Modern dark theme with custom styling** 43 | - 🖥️ **Dual-pane file view (Local/Remote)** 44 | - 🔔 **System tray integration with notifications** 45 | - ⚙️ **Easy-to-use settings dialog** 46 | 47 | ### 🛠️ Technical Features 48 | - 🎯 **Single instance application support** 49 | - 🌐 **Cross-platform (Windows/Linux)** 50 | - 📝 **Detailed logging system** 51 | - 🔌 **Automatic reconnection handling** 52 | 53 | ## 📸 Screenshots 54 | 55 | ### Main Window 56 |

57 | GOSync Main Window 58 |
59 | Main interface showing local and remote file synchronization panels 60 |

61 | 62 | ### Settings Dialog 63 |

64 | GOSync Settings 65 |
66 | Settings window for configuring SSH connection and sync preferences 67 |

68 | 69 | ## 🚀 Installation 70 | 71 | ### System Requirements 72 | - Windows or Linux operating system 73 | - Python 3.8 or higher 74 | - pip (Python package manager) 75 | 76 | ### Dependencies 77 | ```bash 78 | pip install -r requirements.txt 79 | ``` 80 | 81 | Required packages: 82 | - PySide6 >= 6.6.1 (GUI framework) 83 | - paramiko >= 3.4.0 (SSH client) 84 | - cryptography >= 42.0.2 (Encryption) 85 | - scp >= 0.14.5 (File transfer) 86 | 87 | ## 🔧 Configuration 88 | 89 | Settings are automatically saved in: 90 | - Windows: `%APPDATA%\GOSync` 91 | - Linux: `~/.config/GOSync` 92 | 93 | ## 📖 Usage Guide 94 | 95 | ### Initial Setup 96 | 1. Launch GOSync 97 | 2. Click "Settings" to configure: 98 | - SSH server details (hostname, username) 99 | - Authentication (SSH key or password) 100 | - Local and remote sync folders 101 | 102 | ### Sync Operations 103 | - 🟢 **Start Sync**: Begin automatic synchronization 104 | - 🔄 **Sync Now**: Perform immediate sync 105 | - 🔴 **Stop Sync**: Pause synchronization 106 | 107 | ### File Operations 108 | - Right-click on local files: 109 | - **Upload to Server** 110 | - **Delete** 111 | - Right-click on remote files: 112 | - **Download to Local** 113 | - **Refresh List** 114 | 115 | ### System Tray 116 | - **Double-click**: Show/hide window 117 | - **Right-click menu**: 118 | - **Show window** 119 | - **Quick sync** 120 | - **Access settings** 121 | - **Quit application** 122 | 123 | ## 🤝 Contributing 124 | 125 | 1. **Fork the repository** 126 | 2. **Create a feature branch** 127 | 3. **Commit your changes** 128 | 4. **Push to the branch** 129 | 5. **Create a Pull Request** 130 | 131 | ## 📄 License 132 | 133 | This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details. -------------------------------------------------------------------------------- /assets/app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Efeckc17/GoSync/91605d3ee70cdc7c6bf6ece023cfbf969974f4f2/assets/app.png -------------------------------------------------------------------------------- /assets/for-readme/home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Efeckc17/GoSync/91605d3ee70cdc7c6bf6ece023cfbf969974f4f2/assets/for-readme/home.png -------------------------------------------------------------------------------- /assets/for-readme/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Efeckc17/GoSync/91605d3ee70cdc7c6bf6ece023cfbf969974f4f2/assets/for-readme/settings.png -------------------------------------------------------------------------------- /assets/icons/app.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Efeckc17/GoSync/91605d3ee70cdc7c6bf6ece023cfbf969974f4f2/assets/icons/app.ico -------------------------------------------------------------------------------- /core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /core/config/config_manager.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import platform 4 | import base64 5 | from pathlib import Path 6 | from cryptography.fernet import Fernet 7 | from cryptography.hazmat.primitives import hashes 8 | from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC 9 | import logging 10 | 11 | logger = logging.getLogger('GOSync') 12 | 13 | class ConfigManager: 14 | def __init__(self): 15 | self._setup_paths() 16 | self._setup_encryption() 17 | self.config = self.load_config() 18 | 19 | def _setup_paths(self): 20 | """Setup application paths based on OS""" 21 | system = platform.system().lower() 22 | 23 | if system == 'windows': 24 | # Windows: Use %APPDATA%\GOSync 25 | self.config_dir = os.path.join(os.environ.get('APPDATA', ''), 'GOSync') 26 | self.sync_folder = os.path.join(os.path.expanduser('~'), 'GOSyncFiles') 27 | elif system == 'darwin': 28 | # macOS: Use ~/Library/Application Support/GOSync 29 | self.config_dir = os.path.join( 30 | os.path.expanduser('~'), 31 | 'Library/Application Support/GOSync' 32 | ) 33 | self.sync_folder = os.path.join(os.path.expanduser('~'), 'GOSyncFiles') 34 | else: 35 | # Linux/Unix: Use ~/.config/GOSync 36 | self.config_dir = os.path.join( 37 | os.environ.get('XDG_CONFIG_HOME', os.path.expanduser('~/.config')), 38 | 'GOSync' 39 | ) 40 | self.sync_folder = os.path.join(os.path.expanduser('~'), 'GOSyncFiles') 41 | 42 | # Ensure config directory exists 43 | os.makedirs(self.config_dir, mode=0o700, exist_ok=True) 44 | 45 | # Define config files 46 | self.config_file = os.path.join(self.config_dir, 'config.json') 47 | self.key_file = os.path.join(self.config_dir, '.key') 48 | 49 | def _setup_encryption(self): 50 | """Setup encryption for sensitive data""" 51 | try: 52 | if os.path.exists(self.key_file): 53 | # Load existing key 54 | with open(self.key_file, 'rb') as f: 55 | self.key = f.read() 56 | else: 57 | # Generate new key 58 | self.key = Fernet.generate_key() 59 | # Save key with restricted permissions 60 | with open(self.key_file, 'wb') as f: 61 | os.chmod(self.key_file, 0o600) 62 | f.write(self.key) 63 | 64 | self.cipher = Fernet(self.key) 65 | 66 | except Exception as e: 67 | logger.error(f"Failed to setup encryption: {str(e)}") 68 | raise 69 | 70 | def _encrypt(self, data: str) -> str: 71 | """Encrypt sensitive data""" 72 | try: 73 | return base64.b64encode( 74 | self.cipher.encrypt(data.encode()) 75 | ).decode() 76 | except Exception as e: 77 | logger.error(f"Encryption failed: {str(e)}") 78 | return "" 79 | 80 | def _decrypt(self, encrypted_data: str) -> str: 81 | """Decrypt sensitive data""" 82 | try: 83 | if not encrypted_data: 84 | return "" 85 | return self.cipher.decrypt( 86 | base64.b64decode(encrypted_data.encode()) 87 | ).decode() 88 | except Exception as e: 89 | logger.error(f"Decryption failed: {str(e)}") 90 | return "" 91 | 92 | def load_config(self): 93 | """Load configuration from file or create default""" 94 | default_config = { 95 | "ssh": { 96 | "hostname": "", 97 | "username": "", 98 | "remote_path": "", 99 | "ssh_key": "", 100 | "password": "" 101 | }, 102 | "sync": { 103 | "auto_sync": False, 104 | "sync_interval": 300, # 5 minutes 105 | "local_path": self.sync_folder 106 | } 107 | } 108 | 109 | try: 110 | if os.path.exists(self.config_file): 111 | with open(self.config_file, 'r', encoding='utf-8') as f: 112 | config = json.load(f) 113 | 114 | # Decrypt sensitive data 115 | if 'ssh' in config: 116 | ssh = config['ssh'] 117 | ssh['password'] = self._decrypt(ssh.get('password', '')) 118 | ssh['ssh_key'] = self._decrypt(ssh.get('ssh_key', '')) 119 | 120 | # Update with any missing default values 121 | if 'ssh' not in config: 122 | config['ssh'] = default_config['ssh'] 123 | if 'sync' not in config: 124 | config['sync'] = default_config['sync'] 125 | 126 | return config 127 | 128 | except Exception as e: 129 | logger.error(f"Error loading config: {str(e)}") 130 | 131 | return default_config 132 | 133 | def save_config(self): 134 | """Save current configuration to file""" 135 | try: 136 | # Create a copy of config to encrypt sensitive data 137 | config_to_save = { 138 | "ssh": dict(self.config['ssh']), 139 | "sync": dict(self.config['sync']) 140 | } 141 | 142 | # Encrypt sensitive data 143 | ssh = config_to_save['ssh'] 144 | ssh['password'] = self._encrypt(ssh.get('password', '')) 145 | ssh['ssh_key'] = self._encrypt(ssh.get('ssh_key', '')) 146 | 147 | # Save with restricted permissions 148 | with open(self.config_file, 'w', encoding='utf-8') as f: 149 | os.chmod(self.config_file, 0o600) 150 | json.dump(config_to_save, f, indent=4) 151 | 152 | except Exception as e: 153 | logger.error(f"Error saving config: {str(e)}") 154 | 155 | def get_ssh_settings(self): 156 | """Get SSH connection settings""" 157 | return self.config.get('ssh', {}) 158 | 159 | def get_sync_settings(self): 160 | """Get synchronization settings""" 161 | return self.config.get('sync', {}) 162 | 163 | def save_ssh_settings(self, settings): 164 | """Save SSH connection settings""" 165 | self.config['ssh'] = settings 166 | self.save_config() 167 | 168 | def save_sync_settings(self, settings): 169 | """Save synchronization settings""" 170 | self.config['sync'] = settings 171 | self.save_config() 172 | 173 | def get_sync_folder(self): 174 | """Get the sync folder path""" 175 | return self.sync_folder -------------------------------------------------------------------------------- /core/ssh/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /core/ssh/file_transfer.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | from PySide6.QtCore import QObject, Signal 4 | from scp import SCPClient 5 | import unicodedata 6 | import re 7 | 8 | logger = logging.getLogger('GOSync') 9 | 10 | class FileTransferManager(QObject): 11 | transfer_progress = Signal(str) # Progress message 12 | transfer_complete = Signal(bool, str) # Success, Message 13 | 14 | def __init__(self, ssh_client): 15 | super().__init__() 16 | self.ssh_client = ssh_client 17 | self.sftp = None 18 | 19 | def ensure_sftp(self): 20 | """Ensure SFTP connection is active""" 21 | try: 22 | if not self.sftp or not self.ssh_client.is_connected(): 23 | if not self.ssh_client.is_connected(): 24 | self.ssh_client.connect() 25 | self.sftp = self.ssh_client.client.open_sftp() 26 | return True 27 | except Exception as e: 28 | logger.error(f"Failed to establish SFTP connection: {str(e)}") 29 | return False 30 | 31 | def sanitize_filename(self, filename): 32 | """Sanitize filename to handle special characters""" 33 | # Normalize Unicode characters 34 | filename = unicodedata.normalize('NFKC', filename) 35 | # Replace problematic characters with safe alternatives 36 | filename = re.sub(r'[\\/:*?"<>||]', '_', filename) 37 | return filename 38 | 39 | def download_file(self, remote_file, local_path): 40 | """Download a single file from remote server""" 41 | try: 42 | if not self.ensure_sftp(): 43 | error_msg = "Failed to establish SFTP connection" 44 | logger.error(error_msg) 45 | self.transfer_complete.emit(False, error_msg) 46 | return 47 | 48 | # Create local directory if it doesn't exist 49 | local_path = Path(local_path) 50 | local_path.parent.mkdir(parents=True, exist_ok=True) 51 | 52 | self.transfer_progress.emit(f"Downloading {remote_file}...") 53 | 54 | # Use existing SFTP connection 55 | try: 56 | self.sftp.get(remote_file, str(local_path)) 57 | logger.info(f"Downloaded {remote_file} to {local_path}") 58 | self.transfer_complete.emit(True, f"Downloaded {remote_file} successfully") 59 | except FileNotFoundError: 60 | error_msg = f"File does not exist on the remote server: {remote_file}" 61 | logger.error(error_msg) 62 | self.transfer_complete.emit(False, error_msg) 63 | except Exception as e: 64 | error_msg = f"Failed to download {remote_file}: {str(e)}" 65 | logger.error(error_msg) 66 | self.transfer_complete.emit(False, error_msg) 67 | 68 | except Exception as e: 69 | error_msg = f"Failed to download {remote_file}: {str(e)}" 70 | logger.error(error_msg.encode('utf-8', errors='replace').decode('utf-8')) 71 | self.transfer_complete.emit(False, error_msg) 72 | 73 | def upload_file(self, local_file, remote_path): 74 | """Upload a single file to remote server""" 75 | try: 76 | if not self.ssh_client.is_connected(): 77 | self.transfer_progress.emit("Connecting to server...") 78 | self.ssh_client.connect() 79 | 80 | local_file = Path(local_file) 81 | if not local_file.exists(): 82 | raise FileNotFoundError(f"Local file not found: {local_file}") 83 | 84 | # Sanitize remote path 85 | remote_dir = Path(remote_path).parent 86 | remote_name = self.sanitize_filename(Path(remote_path).name) 87 | remote_path = str(remote_dir / remote_name) 88 | 89 | # Create remote directory if needed 90 | self.ssh_client.client.exec_command(f'mkdir -p "{remote_dir}"') 91 | 92 | self.transfer_progress.emit(f"Uploading {local_file.name}...") 93 | 94 | with SCPClient(self.ssh_client.client.get_transport()) as scp: 95 | scp.put(str(local_file), remote_path) 96 | 97 | logger.info(f"Uploaded {local_file} to {remote_path}") 98 | self.transfer_complete.emit(True, f"Uploaded {local_file.name} successfully") 99 | 100 | except Exception as e: 101 | error_msg = f"Failed to upload {local_file}: {str(e)}" 102 | logger.error(error_msg.encode('utf-8', errors='replace').decode('utf-8')) 103 | self.transfer_complete.emit(False, error_msg) 104 | 105 | def verify_file_exists(self, remote_path): 106 | """Check if file exists on remote server""" 107 | try: 108 | if not self.ensure_sftp(): 109 | return False 110 | 111 | try: 112 | self.sftp.stat(remote_path) 113 | return True 114 | except FileNotFoundError: 115 | return False 116 | except Exception as e: 117 | logger.error(f"Failed to verify remote file: {str(e)}") 118 | return False 119 | 120 | except Exception as e: 121 | logger.error(f"Failed to verify remote file: {str(e)}") 122 | return False 123 | 124 | def get_file_size(self, remote_path): 125 | """Get size of remote file""" 126 | try: 127 | if not self.ssh_client.is_connected(): 128 | self.ssh_client.connect() 129 | 130 | cmd = f'stat -f "%z" "{remote_path}" 2>/dev/null || stat -c "%s" "{remote_path}"' 131 | stdin, stdout, stderr = self.ssh_client.client.exec_command(cmd) 132 | size = stdout.read().decode().strip() 133 | 134 | return int(size) if size.isdigit() else 0 135 | 136 | except Exception as e: 137 | logger.error(f"Failed to get file size: {str(e)}") 138 | return 0 -------------------------------------------------------------------------------- /core/ssh/ssh_client.py: -------------------------------------------------------------------------------- 1 | import paramiko 2 | import logging 3 | from pathlib import Path 4 | import io 5 | from PySide6.QtCore import QThread, Signal, QObject 6 | from scp import SCPClient 7 | 8 | logger = logging.getLogger('GOSync') 9 | 10 | class SSHWorker(QThread): 11 | connected = Signal(bool, str) # Success, Message 12 | operation_complete = Signal(bool, str) # Success, Message 13 | operation_progress = Signal(str) # Progress message 14 | file_list_ready = Signal(list) # List of remote files 15 | 16 | def __init__(self, config): 17 | super().__init__() 18 | self.config = config 19 | self.client = None 20 | self.sftp = None 21 | self.operation = None 22 | self.params = None 23 | self._reconnect_attempts = 3 24 | 25 | def ensure_connected(self): 26 | """Ensure SSH connection is active, reconnect if needed""" 27 | try: 28 | if self.is_connected(): 29 | return True 30 | 31 | for attempt in range(self._reconnect_attempts): 32 | try: 33 | self._connect() 34 | return True 35 | except Exception as e: 36 | logger.warning(f"Connection attempt {attempt + 1} failed: {str(e)}") 37 | if self.client: 38 | self.client.close() 39 | if self.sftp: 40 | self.sftp.close() 41 | self.client = None 42 | self.sftp = None 43 | 44 | raise Exception("Failed to establish SSH connection after multiple attempts") 45 | 46 | except Exception as e: 47 | logger.error(f"Connection failed: {str(e)}") 48 | self.connected.emit(False, str(e)) 49 | return False 50 | 51 | def _connect(self): 52 | """Establish SSH connection""" 53 | try: 54 | ssh_settings = self.config.get_ssh_settings() 55 | 56 | self.client = paramiko.SSHClient() 57 | self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) 58 | 59 | # Prepare authentication 60 | if ssh_settings['ssh_key']: 61 | key_file = io.StringIO(ssh_settings['ssh_key']) 62 | private_key = paramiko.RSAKey.from_private_key(key_file) 63 | auth = {'pkey': private_key} 64 | else: 65 | auth = {'password': ssh_settings['password']} 66 | 67 | # Connect to remote host 68 | self.operation_progress.emit(f"Connecting to {ssh_settings['hostname']}...") 69 | self.client.connect( 70 | hostname=ssh_settings['hostname'], 71 | username=ssh_settings['username'], 72 | **auth 73 | ) 74 | 75 | # Open SFTP session 76 | self.sftp = self.client.open_sftp() 77 | 78 | # Ensure base remote path exists 79 | self._ensure_base_path() 80 | 81 | logger.info(f"Connected to {ssh_settings['hostname']}") 82 | self.connected.emit(True, f"Connected to {ssh_settings['hostname']}") 83 | 84 | except Exception as e: 85 | self.disconnect() 86 | raise Exception(f"SSH connection failed: {str(e)}") 87 | 88 | def _ensure_base_path(self): 89 | """Ensure base remote path exists""" 90 | try: 91 | ssh_settings = self.config.get_ssh_settings() 92 | remote_path = ssh_settings['remote_path'] 93 | 94 | # Split path into components 95 | parts = remote_path.replace('\\', '/').strip('/').split('/') 96 | current = '' 97 | 98 | # Create each directory level if needed 99 | for part in parts: 100 | current = current + '/' + part if current else '/' + part 101 | try: 102 | self.sftp.stat(current) 103 | except FileNotFoundError: 104 | try: 105 | self.sftp.mkdir(current) 106 | logger.info(f"Created remote directory: {current}") 107 | except Exception as e: 108 | logger.error(f"Failed to create remote directory {current}: {str(e)}") 109 | raise 110 | 111 | except Exception as e: 112 | logger.error(f"Failed to ensure base path: {str(e)}") 113 | raise 114 | 115 | def _list_remote_files(self): 116 | """List remote files""" 117 | try: 118 | if not self.ensure_connected(): 119 | return 120 | 121 | ssh_settings = self.config.get_ssh_settings() 122 | remote_path = ssh_settings['remote_path'].replace('\\', '/') 123 | 124 | try: 125 | self.sftp.stat(remote_path) 126 | except FileNotFoundError: 127 | self._ensure_base_path() 128 | 129 | files = [] 130 | self._list_remote_files_recursive(remote_path, '', files) 131 | self.file_list_ready.emit(files) 132 | self.operation_complete.emit(True, "File list retrieved successfully") 133 | 134 | except Exception as e: 135 | logger.error(f"Failed to list remote files: {str(e)}") 136 | self.operation_complete.emit(False, f"Failed to list files: {str(e)}") 137 | 138 | def _upload_file(self): 139 | """Upload file to remote server""" 140 | try: 141 | if not self.ensure_connected(): 142 | return 143 | 144 | local_file = self.params['local_file'] 145 | remote_file = self.params['remote_file'] 146 | 147 | ssh_settings = self.config.get_ssh_settings() 148 | remote_path = Path(ssh_settings['remote_path'].replace('\\', '/')) / remote_file 149 | 150 | # Create remote directories if needed 151 | remote_dir = str(remote_path.parent).replace('\\', '/') 152 | try: 153 | self.sftp.stat(remote_dir) 154 | except FileNotFoundError: 155 | self._create_remote_dirs(remote_dir) 156 | 157 | # Upload file 158 | self.operation_progress.emit(f"Uploading {local_file}...") 159 | self.sftp.put(str(local_file), str(remote_path).replace('\\', '/')) 160 | 161 | logger.info(f"Uploaded {local_file} to {remote_path}") 162 | self.operation_complete.emit(True, f"Uploaded {local_file}") 163 | 164 | except Exception as e: 165 | logger.error(f"Failed to upload {self.params['local_file']}: {str(e)}") 166 | self.operation_complete.emit(False, f"Upload failed: {str(e)}") 167 | 168 | def _create_remote_dirs(self, path): 169 | """Create remote directory hierarchy""" 170 | path = path.replace('\\', '/').strip('/') 171 | current = '' 172 | 173 | for part in path.split('/'): 174 | current = current + '/' + part if current else '/' + part 175 | try: 176 | self.sftp.stat(current) 177 | except FileNotFoundError: 178 | try: 179 | self.sftp.mkdir(current) 180 | logger.info(f"Created remote directory: {current}") 181 | except Exception as e: 182 | if "Socket is closed" in str(e): 183 | if self.ensure_connected(): 184 | self.sftp.mkdir(current) 185 | logger.info(f"Created remote directory after reconnection: {current}") 186 | else: 187 | raise 188 | else: 189 | raise 190 | 191 | def _download_file(self): 192 | """Download file from remote server""" 193 | try: 194 | if not self.ensure_connected(): 195 | return 196 | 197 | remote_file = self.params['remote_file'] 198 | local_file = self.params['local_file'] 199 | 200 | ssh_settings = self.config.get_ssh_settings() 201 | remote_path = Path(ssh_settings['remote_path'].replace('\\', '/')) / remote_file 202 | 203 | # Create local directories if needed 204 | local_file.parent.mkdir(parents=True, exist_ok=True) 205 | 206 | # Download file 207 | self.operation_progress.emit(f"Downloading {remote_file}...") 208 | self.sftp.get(str(remote_path).replace('\\', '/'), str(local_file)) 209 | 210 | logger.info(f"Downloaded {remote_path} to {local_file}") 211 | self.operation_complete.emit(True, f"Downloaded {remote_file}") 212 | 213 | except Exception as e: 214 | logger.error(f"Failed to download {self.params['remote_file']}: {str(e)}") 215 | self.operation_complete.emit(False, f"Download failed: {str(e)}") 216 | 217 | def disconnect(self): 218 | """Close SSH connection""" 219 | if self.sftp: 220 | self.sftp.close() 221 | self.sftp = None 222 | 223 | if self.client: 224 | self.client.close() 225 | self.client = None 226 | 227 | logger.info("Disconnected from SSH") 228 | 229 | def is_connected(self): 230 | """Check if SSH connection is active""" 231 | if not self.client or not self.sftp: 232 | return False 233 | 234 | try: 235 | self.client.get_transport().is_active() 236 | return True 237 | except: 238 | return False 239 | 240 | def _list_remote_files_recursive(self, remote_path, relative_path, files): 241 | """Recursively list files in remote directory""" 242 | try: 243 | for entry in self.sftp.listdir_attr(str(Path(remote_path) / relative_path)): 244 | full_path = str(Path(relative_path) / entry.filename) 245 | 246 | if self._is_file(entry): 247 | files.append(full_path) 248 | elif self._is_dir(entry): 249 | self._list_remote_files_recursive(remote_path, full_path, files) 250 | 251 | except Exception as e: 252 | logger.error(f"Failed to list directory {relative_path}: {str(e)}") 253 | 254 | def _is_file(self, entry): 255 | """Check if SFTP entry is a regular file""" 256 | return entry.st_mode & 0o170000 == 0o100000 257 | 258 | def _is_dir(self, entry): 259 | """Check if SFTP entry is a directory""" 260 | return entry.st_mode & 0o170000 == 0o040000 261 | 262 | def run(self): 263 | """Execute the requested SSH operation""" 264 | try: 265 | if self.operation == 'connect': 266 | self._connect() 267 | elif self.operation == 'list_files': 268 | self._list_remote_files() 269 | elif self.operation == 'upload': 270 | self._upload_file() 271 | elif self.operation == 'download': 272 | self._download_file() 273 | except Exception as e: 274 | logger.error(f"SSH operation failed: {str(e)}") 275 | self.operation_complete.emit(False, str(e)) 276 | 277 | class SSHClient(QObject): 278 | def __init__(self, config): 279 | super().__init__() 280 | self.config = config 281 | self.client = None 282 | self._setup_client() 283 | self.worker = None 284 | 285 | def _setup_client(self): 286 | """Initialize SSH client with default settings""" 287 | self.client = paramiko.SSHClient() 288 | self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) 289 | 290 | def connect(self): 291 | """Connect to remote server using either password or key-based auth""" 292 | if not self.client: 293 | self._setup_client() 294 | 295 | try: 296 | ssh_settings = self.config.get_ssh_settings() 297 | 298 | if ssh_settings.get('password'): 299 | # Password authentication 300 | self.client.connect( 301 | hostname=ssh_settings['hostname'], 302 | username=ssh_settings['username'], 303 | password=ssh_settings['password'], 304 | look_for_keys=False, 305 | allow_agent=False 306 | ) 307 | else: 308 | # Key-based authentication 309 | self.client.connect( 310 | hostname=ssh_settings['hostname'], 311 | username=ssh_settings['username'], 312 | key_filename=ssh_settings['key_path'], 313 | look_for_keys=False, 314 | allow_agent=False 315 | ) 316 | logger.info("SSH connection established successfully") 317 | except Exception as e: 318 | logger.error(f"SSH connection failed: {str(e)}") 319 | raise 320 | 321 | def disconnect(self): 322 | """Close SSH connection""" 323 | if self.client: 324 | self.client.close() 325 | self.client = None 326 | 327 | if self.worker: 328 | self.worker.disconnect() 329 | self.worker = None 330 | 331 | def is_connected(self): 332 | """Check if SSH connection is active""" 333 | return (self.client and 334 | self.client.get_transport() and 335 | self.client.get_transport().is_active()) 336 | 337 | def list_remote_files(self): 338 | """Get list of files in remote directory""" 339 | try: 340 | if not self.is_connected(): 341 | self.connect() 342 | 343 | ssh_settings = self.config.get_ssh_settings() 344 | remote_path = ssh_settings.get('remote_path', '') 345 | 346 | if not remote_path: 347 | raise ValueError("Remote path not configured") 348 | 349 | stdin, stdout, stderr = self.client.exec_command( 350 | f'find "{remote_path}" -type f -printf "%P\\n"' 351 | ) 352 | return [line.strip() for line in stdout if line.strip()] 353 | except Exception as e: 354 | logger.error(f"Failed to list remote files: {str(e)}") 355 | raise 356 | 357 | def upload_file(self, local_path, remote_path): 358 | """Upload a file using SCP""" 359 | try: 360 | if not self.is_connected(): 361 | self.connect() 362 | 363 | ssh_settings = self.config.get_ssh_settings() 364 | base_remote_path = ssh_settings.get('remote_path', '') 365 | 366 | if not base_remote_path: 367 | raise ValueError("Remote path not configured") 368 | 369 | full_remote_path = str(Path(base_remote_path) / remote_path) 370 | remote_dir = str(Path(full_remote_path).parent) 371 | 372 | self.client.exec_command(f'mkdir -p "{remote_dir}"') 373 | 374 | with SCPClient(self.client.get_transport()) as scp: 375 | scp.put(str(local_path), full_remote_path) 376 | logger.info(f"File uploaded successfully: {full_remote_path}") 377 | except Exception as e: 378 | logger.error(f"File upload failed: {str(e)}") 379 | raise 380 | 381 | def download_file(self, remote_path, local_path): 382 | """Download a file using SCP""" 383 | try: 384 | if not self.is_connected(): 385 | self.connect() 386 | 387 | ssh_settings = self.config.get_ssh_settings() 388 | base_remote_path = ssh_settings.get('remote_path', '') 389 | 390 | if not base_remote_path: 391 | raise ValueError("Remote path not configured") 392 | 393 | full_remote_path = str(Path(base_remote_path) / remote_path) 394 | local_dir = Path(local_path).parent 395 | local_dir.mkdir(parents=True, exist_ok=True) 396 | 397 | with SCPClient(self.client.get_transport()) as scp: 398 | scp.get(full_remote_path, str(local_path)) 399 | logger.info(f"File downloaded successfully: {local_path}") 400 | except Exception as e: 401 | logger.error(f"File download failed: {str(e)}") 402 | raise 403 | 404 | def get_remote_mtime(self, remote_path): 405 | """Get modification time of remote file""" 406 | try: 407 | if not self.is_connected(): 408 | self.connect() 409 | 410 | ssh_settings = self.config.get_ssh_settings() 411 | base_remote_path = ssh_settings.get('remote_path', '') 412 | 413 | if not base_remote_path: 414 | raise ValueError("Remote path not configured") 415 | 416 | full_remote_path = str(Path(base_remote_path) / remote_path) 417 | 418 | stdin, stdout, stderr = self.client.exec_command( 419 | f'stat -c %Y "{full_remote_path}"' 420 | ) 421 | mtime = stdout.read().decode().strip() 422 | return float(mtime) if mtime else 0 423 | except Exception as e: 424 | logger.error(f"Failed to get remote mtime: {str(e)}") 425 | return 0 426 | 427 | def start_worker(self): 428 | """Start the SSH worker""" 429 | if not self.worker: 430 | self.worker = SSHWorker(self.config) 431 | 432 | self.worker.operation = 'connect' 433 | self.worker.start() 434 | 435 | def wait_for_completion(self): 436 | """Wait for the SSH worker to complete""" 437 | if self.worker: 438 | self.worker.wait() 439 | 440 | def get_file_list(self): 441 | """Get the list of remote files""" 442 | if not self.worker: 443 | self.worker = SSHWorker(self.config) 444 | 445 | self.worker.operation = 'list_files' 446 | self.worker.start() 447 | self.worker.wait() # Wait for completion since we need the result 448 | return [] # Return empty list, actual results will come through signal 449 | 450 | def start_upload(self, local_file, remote_file): 451 | """Start the upload worker""" 452 | if not self.worker: 453 | self.worker = SSHWorker(self.config) 454 | 455 | self.worker.operation = 'upload' 456 | self.worker.params = { 457 | 'local_file': local_file, 458 | 'remote_file': remote_file 459 | } 460 | self.worker.start() 461 | 462 | def start_download(self, remote_file, local_file): 463 | """Start the download worker""" 464 | if not self.worker: 465 | self.worker = SSHWorker(self.config) 466 | 467 | self.worker.operation = 'download' 468 | self.worker.params = { 469 | 'remote_file': remote_file, 470 | 'local_file': local_file 471 | } 472 | self.worker.start() 473 | 474 | def get_remote_mtime(self, remote_file): 475 | """Get remote file modification time""" 476 | if not self.is_connected(): 477 | return 0 478 | 479 | try: 480 | ssh_settings = self.config.get_ssh_settings() 481 | remote_path = Path(ssh_settings['remote_path']) / remote_file 482 | stat = self.client.exec_command(f'stat -c %Y "{remote_path}"')[1].read().decode().strip() 483 | return float(stat) if stat else 0 484 | except Exception as e: 485 | logger.error(f"Failed to get mtime for {remote_file}: {str(e)}") 486 | return 0 -------------------------------------------------------------------------------- /core/sync/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /core/sync/sync_manager.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | import tempfile 4 | import time 5 | from pathlib import Path 6 | from PySide6.QtCore import QThread, Signal, QFileSystemWatcher, QObject 7 | from core.ssh.ssh_client import SSHClient 8 | from scp import SCPClient 9 | 10 | logger = logging.getLogger('GOSync') 11 | 12 | class SyncWorker(QThread): 13 | sync_complete = Signal(bool, str) # Success, Message 14 | sync_progress = Signal(str) # Progress message 15 | files_updated = Signal(list, list) # Local files, Remote files 16 | 17 | def __init__(self, config): 18 | super().__init__() 19 | self.config = config 20 | self.ssh_client = SSHClient(config) 21 | self.running = False 22 | self.auto_sync = False 23 | self.sent_files = set() 24 | self.check_interval = 10 # 10 seconds interval 25 | 26 | def run(self): 27 | """Main worker thread""" 28 | self.running = True 29 | while self.running: 30 | try: 31 | self.sync_now() 32 | if not self.auto_sync: 33 | break 34 | # Sleep for check_interval seconds 35 | self.msleep(self.check_interval * 1000) 36 | except Exception as e: 37 | logger.error(f"Sync error: {str(e)}") 38 | self.sync_complete.emit(False, str(e)) 39 | if not self.auto_sync: 40 | break 41 | # Even on error, continue checking after interval 42 | self.msleep(self.check_interval * 1000) 43 | 44 | def stop(self): 45 | """Stop the worker thread""" 46 | self.running = False 47 | self.wait() 48 | 49 | def sync_now(self): 50 | """Perform immediate synchronization""" 51 | try: 52 | sync_settings = self.config.get_sync_settings() 53 | local_path = Path(sync_settings['local_path']) 54 | 55 | self.sync_progress.emit("Connecting to SSH...") 56 | if not self.ssh_client.is_connected(): 57 | self.ssh_client.connect() 58 | 59 | self.sync_progress.emit("Getting file lists...") 60 | local_files = self._get_local_files(local_path) 61 | remote_files = self.fetch_remote_filelist() 62 | 63 | self.files_updated.emit(local_files, list(remote_files)) 64 | 65 | self.sync_progress.emit("Comparing files...") 66 | to_send = [] 67 | for file in local_files: 68 | file_lower = file.lower() 69 | if file_lower not in remote_files: 70 | self.sync_progress.emit(f"Ready to send: {file}") 71 | to_send.append(file) 72 | else: 73 | logger.debug(f"Skipping: {file} already exists on server") 74 | 75 | if to_send: 76 | self.sync_progress.emit(f"{len(to_send)} files will be sent: {', '.join(to_send)}") 77 | self.sync_progress.emit("Transferring files...") 78 | self._sync_files(to_send, local_path) 79 | self.sync_complete.emit(True, f"Sync completed successfully. Sent {len(to_send)} files.") 80 | else: 81 | logger.debug("No new files to send") 82 | self.sync_complete.emit(True, "No new files to sync") 83 | 84 | except Exception as e: 85 | logger.error(f"Sync failed: {str(e)}") 86 | self.sync_complete.emit(False, f"Sync failed: {str(e)}") 87 | 88 | def _get_local_files(self, path): 89 | """Get list of local files""" 90 | files = [] 91 | for root, _, filenames in os.walk(path): 92 | for filename in filenames: 93 | full_path = Path(root) / filename 94 | rel_path = str(full_path.relative_to(path)) 95 | files.append(rel_path) 96 | return files 97 | 98 | def fetch_remote_filelist(self): 99 | """Get list of remote files using temporary file approach""" 100 | try: 101 | ssh_settings = self.config.get_ssh_settings() 102 | remote_path = ssh_settings['remote_path'] 103 | 104 | # Create temporary file on remote server 105 | remote_txt = os.path.join(remote_path, "filelist.txt").replace("\\", "/") 106 | self.ssh_client.client.exec_command( 107 | f'find "{remote_path}" -type f -printf "%P\\n" > "{remote_txt}"' 108 | ) 109 | 110 | # Wait for file creation 111 | time.sleep(1) 112 | 113 | # Download and read the file 114 | local_txt = tempfile.mktemp(suffix=".txt") 115 | try: 116 | with SCPClient(self.ssh_client.client.get_transport()) as scp: 117 | scp.get(remote_txt, local_txt) 118 | 119 | with open(local_txt, "r", encoding="utf-8") as f: 120 | remote_files = set(line.strip().lower() for line in f if line.strip()) 121 | 122 | return remote_files 123 | finally: 124 | if os.path.exists(local_txt): 125 | os.remove(local_txt) 126 | # Clean up remote file 127 | self.ssh_client.client.exec_command(f'rm -f "{remote_txt}"') 128 | 129 | except Exception as e: 130 | logger.error(f"Failed to fetch remote file list: {str(e)}") 131 | raise 132 | 133 | def _sync_files(self, to_send, local_path): 134 | """Synchronize files between local and remote""" 135 | ssh_settings = self.config.get_ssh_settings() 136 | remote_base = ssh_settings['remote_path'] 137 | 138 | for file in to_send: 139 | if not self.running: 140 | break 141 | 142 | try: 143 | local_file = local_path / file 144 | remote_dir = os.path.dirname(os.path.join(remote_base, file)).replace("\\", "/") 145 | 146 | # Ensure remote directory exists 147 | self.ssh_client.client.exec_command(f'mkdir -p "{remote_dir}"') 148 | 149 | # Upload file 150 | self.sync_progress.emit(f"Uploading {file}") 151 | with SCPClient(self.ssh_client.client.get_transport()) as scp: 152 | scp.put(str(local_file), os.path.join(remote_base, file).replace("\\", "/")) 153 | 154 | self.sent_files.add(file.lower()) 155 | logger.info(f"Uploaded {file}") 156 | 157 | except Exception as e: 158 | logger.error(f"Failed to upload {file}: {str(e)}") 159 | self.sync_progress.emit(f"Error uploading {file}: {str(e)}") 160 | 161 | class SyncManager(QObject): 162 | def __init__(self, config): 163 | super().__init__() 164 | self.config = config 165 | self.watcher = None 166 | self.sync_worker = None 167 | self.pending_files = set() # Track files pending upload 168 | 169 | def start_sync(self): 170 | """Start automatic synchronization""" 171 | sync_settings = self.config.get_sync_settings() 172 | local_path = Path(sync_settings['local_path']) 173 | local_path.mkdir(parents=True, exist_ok=True) 174 | 175 | # Start file system watcher 176 | if not self.watcher: 177 | self.watcher = QFileSystemWatcher() 178 | self.watcher.directoryChanged.connect(self._on_directory_changed) 179 | self.watcher.fileChanged.connect(self._on_file_changed) 180 | 181 | # Add local path and its subdirectories to watcher 182 | self._add_watch_paths(local_path) 183 | 184 | # Start sync worker 185 | if not self.sync_worker: 186 | self.sync_worker = SyncWorker(self.config) 187 | self.sync_worker.sync_complete.connect(self._on_sync_complete) 188 | self.sync_worker.sync_progress.connect(self._on_sync_progress) 189 | 190 | # Enable continuous sync 191 | self.sync_worker.auto_sync = True 192 | self.sync_worker.start() 193 | 194 | logger.info("Continuous sync started with 10-second interval") 195 | 196 | def stop_sync(self): 197 | """Stop automatic synchronization""" 198 | if self.watcher: 199 | self.watcher.removePaths(self.watcher.directories()) 200 | self.watcher.removePaths(self.watcher.files()) 201 | self.watcher = None 202 | 203 | if self.sync_worker: 204 | self.sync_worker.running = False 205 | self.sync_worker.auto_sync = False 206 | self.sync_worker.wait() 207 | self.sync_worker = None 208 | 209 | logger.info("Sync stopped") 210 | 211 | def sync_now(self): 212 | """Perform immediate synchronization""" 213 | if self.sync_worker and self.sync_worker.isRunning(): 214 | logger.info("Sync already running") 215 | return 216 | 217 | self.sync_worker = SyncWorker(self.config) 218 | self.sync_worker.sync_complete.connect(self._on_sync_complete) 219 | self.sync_worker.sync_progress.connect(self._on_sync_progress) 220 | self.sync_worker.auto_sync = False 221 | self.sync_worker.start() 222 | 223 | def _add_watch_paths(self, path): 224 | """Add directory and its subdirectories to watcher""" 225 | try: 226 | # Add main directory 227 | self.watcher.addPath(str(path)) 228 | logger.info(f"Watching directory: {path}") 229 | 230 | # Add all subdirectories and files 231 | for root, dirs, files in os.walk(path): 232 | root_path = Path(root) 233 | # Add directories 234 | for dir_name in dirs: 235 | dir_path = root_path / dir_name 236 | self.watcher.addPath(str(dir_path)) 237 | logger.info(f"Watching directory: {dir_path}") 238 | # Add files 239 | for file_name in files: 240 | file_path = root_path / file_name 241 | self.watcher.addPath(str(file_path)) 242 | logger.info(f"Watching file: {file_path}") 243 | # Add to pending files if not already synced 244 | if not self.sync_worker or file_name.lower() not in self.sync_worker.sent_files: 245 | self.pending_files.add(str(file_path)) 246 | except Exception as e: 247 | logger.error(f"Failed to add watch paths: {str(e)}") 248 | 249 | def _on_directory_changed(self, path): 250 | """Handle directory change events""" 251 | try: 252 | path = Path(path) 253 | logger.info(f"Directory changed: {path}") 254 | 255 | # Get sync settings 256 | sync_settings = self.config.get_sync_settings() 257 | local_base = Path(sync_settings['local_path']) 258 | 259 | # Check for new files 260 | for item in path.glob('*'): 261 | if item.is_file(): 262 | rel_path = item.relative_to(local_base) 263 | if not self.sync_worker or str(rel_path).lower() not in self.sync_worker.sent_files: 264 | self.pending_files.add(str(item)) 265 | logger.info(f"New file detected: {item}") 266 | elif item.is_dir(): 267 | # Add new directory to watcher 268 | self._add_watch_paths(item) 269 | 270 | # Trigger sync if there are pending files 271 | if self.pending_files: 272 | self._sync_pending_files() 273 | 274 | except Exception as e: 275 | logger.error(f"Error handling directory change: {str(e)}") 276 | 277 | def _on_file_changed(self, path): 278 | """Handle file change events""" 279 | try: 280 | path = Path(path) 281 | logger.info(f"File changed: {path}") 282 | 283 | if path.exists(): # File was modified 284 | self.pending_files.add(str(path)) 285 | self._sync_pending_files() 286 | 287 | except Exception as e: 288 | logger.error(f"Error handling file change: {str(e)}") 289 | 290 | def _sync_pending_files(self): 291 | """Sync pending files to remote server""" 292 | if not self.sync_worker or not self.sync_worker.isRunning(): 293 | sync_settings = self.config.get_sync_settings() 294 | local_base = Path(sync_settings['local_path']) 295 | 296 | # Convert absolute paths to relative paths 297 | relative_paths = [] 298 | for file_path in self.pending_files: 299 | try: 300 | rel_path = str(Path(file_path).relative_to(local_base)) 301 | relative_paths.append(rel_path) 302 | except Exception as e: 303 | logger.error(f"Error converting path {file_path}: {str(e)}") 304 | 305 | if relative_paths: 306 | logger.info(f"Syncing pending files: {relative_paths}") 307 | self.sync_worker = SyncWorker(self.config) 308 | self.sync_worker.sync_complete.connect(self._on_sync_complete) 309 | self.sync_worker.sync_progress.connect(self._on_sync_progress) 310 | self.sync_worker.auto_sync = False 311 | self.sync_worker.start() 312 | 313 | def _on_sync_complete(self, success, message): 314 | """Handle sync completion""" 315 | if success: 316 | self.pending_files.clear() 317 | logger.info(f"Sync complete: {message}") 318 | 319 | def _on_sync_progress(self, message): 320 | """Handle sync progress""" 321 | logger.info(f"Sync progress: {message}") -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import logging 3 | from PySide6.QtWidgets import QApplication 4 | from PySide6.QtCore import QSharedMemory, Qt 5 | from PySide6.QtNetwork import QLocalServer, QLocalSocket 6 | from ui.windows.main_window import MainWindow 7 | from core.config.config_manager import ConfigManager 8 | 9 | # Setup logging 10 | logging.basicConfig( 11 | level=logging.INFO, 12 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' 13 | ) 14 | 15 | class SingleApplication(QApplication): 16 | def __init__(self, argv): 17 | super().__init__(argv) 18 | 19 | self.shared_memory = QSharedMemory('GOSyncApplication') 20 | 21 | # Try to create shared memory 22 | if self.shared_memory.create(1): 23 | # First instance - set up server 24 | self.is_first = True 25 | self.server = QLocalServer() 26 | self.server.listen('GOSyncServer') 27 | self.server.newConnection.connect(self.handle_connection) 28 | else: 29 | # Second instance - connect to first 30 | self.is_first = False 31 | socket = QLocalSocket() 32 | socket.connectToServer('GOSyncServer') 33 | if socket.waitForConnected(500): 34 | socket.write(b'show') 35 | socket.waitForBytesWritten() 36 | sys.exit(0) # Exit second instance 37 | 38 | def handle_connection(self): 39 | """Handle connection from second instance""" 40 | socket = self.server.nextPendingConnection() 41 | if socket.waitForReadyRead(500): 42 | # Show and raise main window 43 | if hasattr(self, 'main_window'): 44 | self.main_window.show() 45 | self.main_window.raise_() 46 | self.main_window.activateWindow() 47 | 48 | def main(): 49 | # Create application 50 | app = SingleApplication(sys.argv) 51 | 52 | if not app.is_first: 53 | return 54 | 55 | # Create config manager 56 | config = ConfigManager() 57 | 58 | # Create main window 59 | main_window = MainWindow(config) 60 | app.main_window = main_window # Store reference for handle_connection 61 | main_window.show() 62 | 63 | # Start application 64 | return app.exec() 65 | 66 | if __name__ == '__main__': 67 | sys.exit(main()) -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PySide6>=6.6.1 2 | paramiko>=3.4.0 3 | cryptography>=42.0.2 4 | scp>=0.14.5 -------------------------------------------------------------------------------- /themes/ui.qss: -------------------------------------------------------------------------------- 1 | /* Base Styles */ 2 | * { 3 | font-family: 'Segoe UI', sans-serif; 4 | font-size: 10pt; 5 | margin: 0; 6 | padding: 0; 7 | } 8 | 9 | QWidget { 10 | background-color: #1e1e1e; 11 | color: #ffffff; 12 | } 13 | 14 | QWidget:focus { 15 | outline: none; 16 | border: none; 17 | } 18 | 19 | /* Labels */ 20 | QLabel { 21 | background: transparent; 22 | color: #ffffff; 23 | font-size: 12pt; 24 | } 25 | 26 | QLabel#titleLabel { 27 | font-size: 24pt; 28 | font-weight: bold; 29 | color: #ff4444; 30 | padding: 15px; 31 | margin-bottom: 10px; 32 | } 33 | 34 | QLabel#sectionLabel { 35 | font-size: 14pt; 36 | font-weight: bold; 37 | color: #ff6666; 38 | padding: 8px; 39 | margin-top: 5px; 40 | } 41 | 42 | /* Frames */ 43 | QFrame { 44 | background: transparent; 45 | border: none; 46 | } 47 | 48 | /* Buttons */ 49 | QPushButton { 50 | background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #ff4444, stop:1 #ff6666); 51 | color: #fff; 52 | border: none; 53 | border-radius: 16px; 54 | padding: 12px 28px; 55 | font-size: 12pt; 56 | font-weight: bold; 57 | min-width: 120px; 58 | } 59 | 60 | QPushButton:hover { 61 | background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #ff6666, stop:1 #ff4444); 62 | } 63 | 64 | QPushButton:pressed { 65 | background-color: #cc3333; 66 | } 67 | 68 | QPushButton:disabled { 69 | background-color: #333333; 70 | color: #666666; 71 | } 72 | 73 | QPushButton#dangerButton { 74 | background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #ff0000, stop:1 #cc0000); 75 | } 76 | 77 | QPushButton#dangerButton:hover { 78 | background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #cc0000, stop:1 #ff0000); 79 | } 80 | 81 | /* List Widget */ 82 | QListWidget { 83 | background: #232323; 84 | color: #fff; 85 | border: none; 86 | border-radius: 20px; 87 | padding: 18px 12px; 88 | } 89 | 90 | QListWidget::item { 91 | background: transparent; 92 | color: #fff; 93 | padding: 10px 16px; 94 | margin: 6px; 95 | border-radius: 16px; 96 | font-size: 14px; 97 | font-weight: normal; 98 | min-height: 36px; 99 | line-height: 1.4em; 100 | } 101 | 102 | QListWidget::item:nth-child(even) { 103 | background-color: rgba(255, 255, 255, 0.02); 104 | } 105 | 106 | QListWidget::item:nth-child(odd) { 107 | background-color: transparent; 108 | } 109 | 110 | QListWidget::item:hover:!selected { 111 | background-color: rgba(255,68,68,0.25); 112 | border: 2px solid #ff4444; 113 | } 114 | 115 | QListWidget::item:selected { 116 | background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #ff4444, stop:1 #ff6666); 117 | border: none; 118 | color: #ffffff; 119 | font-weight: bold; 120 | } 121 | 122 | /* Menu */ 123 | QMenu { 124 | background: #232323; 125 | color: #fff; 126 | border: 2px solid #444; 127 | border-radius: 16px; 128 | padding: 8px; 129 | } 130 | 131 | QMenu::item { 132 | padding: 10px 28px 10px 24px; 133 | border-radius: 10px; 134 | } 135 | 136 | QMenu::item:selected { 137 | background-color: #ff4444; 138 | } 139 | 140 | QMenu::separator { 141 | height: 1px; 142 | background-color: #444; 143 | margin: 5px 0px; 144 | } 145 | 146 | /* Progress Bar */ 147 | QProgressBar { 148 | height: 24px; 149 | border-radius: 16px; 150 | background: rgba(255,255,255,0.1); 151 | text-align: center; 152 | font-weight: bold; 153 | color: #fff; 154 | margin: 0 14px; 155 | } 156 | 157 | QProgressBar::chunk { 158 | border-radius: 16px; 159 | background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #ff4444, stop:1 #ff6666); 160 | } 161 | 162 | /* Scroll Bars */ 163 | QScrollBar:vertical, 164 | QScrollBar:horizontal { 165 | background: #232323; 166 | border-radius: 8px; 167 | width: 14px; 168 | height: 14px; 169 | } 170 | 171 | QScrollBar::handle:vertical, 172 | QScrollBar::handle:horizontal { 173 | min-width: 28px; 174 | min-height: 28px; 175 | border-radius: 8px; 176 | background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #444, stop:1 #666); 177 | } 178 | 179 | QScrollBar::handle:hover { 180 | background-color: #ff4444; 181 | } 182 | 183 | QScrollBar::add-line, 184 | QScrollBar::sub-line { 185 | width: 0; 186 | height: 0; 187 | } 188 | 189 | /* Status Bar */ 190 | QStatusBar { 191 | background-color: #232323; 192 | color: #ffffff; 193 | padding: 10px; 194 | border-top: 2px solid #444; 195 | font-size: 11pt; 196 | } 197 | 198 | /* Line Edit */ 199 | QLineEdit { 200 | background-color: rgba(42,42,42,0.85); 201 | color: #fff; 202 | border: 2px solid #444; 203 | border-radius: 16px; 204 | padding: 12px; 205 | } 206 | 207 | QLineEdit:focus { 208 | background-color: #222; 209 | border: 2px solid #ff4444; 210 | } 211 | 212 | /* Text Edit */ 213 | QTextEdit { 214 | background: #232323; 215 | color: #fff; 216 | border: 2px solid #444; 217 | border-radius: 16px; 218 | padding: 12px; 219 | } 220 | 221 | QTextEdit:focus { 222 | border: 2px solid #ff4444; 223 | } 224 | 225 | /* Tool Tips */ 226 | QToolTip { 227 | background-color: #232323; 228 | color: #ffffff; 229 | border: 2px solid #ff4444; 230 | padding: 8px; 231 | border-radius: 8px; 232 | font-size: 11pt; 233 | } -------------------------------------------------------------------------------- /ui/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/widgets/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/widgets/file_list_widget.py: -------------------------------------------------------------------------------- 1 | from PySide6.QtWidgets import QListWidget, QListWidgetItem 2 | from PySide6.QtCore import Qt 3 | 4 | class FileListWidget(QListWidget): 5 | def __init__(self, parent=None): 6 | super().__init__(parent) 7 | self.setup_ui() 8 | 9 | def setup_ui(self): 10 | self.setAcceptDrops(True) 11 | self.setDragEnabled(True) 12 | self.setStyleSheet(""" 13 | QListWidget { 14 | background-color: #2d2d2d; 15 | border: 1px solid #3d3d3d; 16 | border-radius: 4px; 17 | color: #FFFFFF; 18 | } 19 | QListWidget::item { 20 | padding: 4px; 21 | } 22 | QListWidget::item:selected { 23 | background-color: #4d4d4d; 24 | } 25 | QListWidget::item:hover { 26 | background-color: #3d3d3d; 27 | } 28 | """) 29 | 30 | def update_files(self, files): 31 | """Update the list with new files""" 32 | self.clear() 33 | for file in files: 34 | item = QListWidgetItem(file) 35 | self.addItem(item) 36 | 37 | def dragEnterEvent(self, event): 38 | if event.mimeData().hasUrls(): 39 | event.accept() 40 | else: 41 | event.ignore() 42 | 43 | def dropEvent(self, event): 44 | files = [] 45 | for url in event.mimeData().urls(): 46 | files.append(url.toLocalFile()) 47 | self.update_files(files) -------------------------------------------------------------------------------- /ui/widgets/settings_dialog.py: -------------------------------------------------------------------------------- 1 | from PySide6.QtWidgets import ( 2 | QDialog, QVBoxLayout, QHBoxLayout, QLabel, 3 | QLineEdit, QPushButton, QTextEdit, QFileDialog 4 | ) 5 | from PySide6.QtCore import Qt, QFile 6 | import os 7 | import sys 8 | 9 | def get_resource_path(relative_path): 10 | 11 | try: 12 | 13 | base_path = sys._MEIPASS 14 | except Exception: 15 | base_path = os.path.abspath(".") 16 | return os.path.join(base_path, relative_path) 17 | 18 | class SettingsDialog(QDialog): 19 | def __init__(self, config, parent=None): 20 | super().__init__(parent) 21 | self.config = config 22 | self.setup_ui() 23 | self.load_stylesheet() 24 | self.load_settings() 25 | 26 | def setup_ui(self): 27 | self.setWindowTitle("GOSync Settings") 28 | layout = QVBoxLayout(self) 29 | 30 | # Local Path 31 | local_path_layout = QHBoxLayout() 32 | local_path_label = QLabel("Local Folder") 33 | self.local_path_input = QLineEdit() 34 | browse_button = QPushButton("Browse") 35 | browse_button.clicked.connect(self.browse_local_path) 36 | local_path_layout.addWidget(local_path_label) 37 | local_path_layout.addWidget(self.local_path_input) 38 | local_path_layout.addWidget(browse_button) 39 | layout.addLayout(local_path_layout) 40 | 41 | # Hostname 42 | hostname_layout = QHBoxLayout() 43 | hostname_label = QLabel("Hostname") 44 | self.hostname_input = QLineEdit() 45 | hostname_layout.addWidget(hostname_label) 46 | hostname_layout.addWidget(self.hostname_input) 47 | layout.addLayout(hostname_layout) 48 | 49 | # Username 50 | username_layout = QHBoxLayout() 51 | username_label = QLabel("Username") 52 | self.username_input = QLineEdit() 53 | username_layout.addWidget(username_label) 54 | username_layout.addWidget(self.username_input) 55 | layout.addLayout(username_layout) 56 | 57 | # Remote Path 58 | remote_path_layout = QHBoxLayout() 59 | remote_path_label = QLabel("Remote Path") 60 | self.remote_path_input = QLineEdit() 61 | remote_path_layout.addWidget(remote_path_label) 62 | remote_path_layout.addWidget(self.remote_path_input) 63 | layout.addLayout(remote_path_layout) 64 | 65 | # SSH Private Key 66 | ssh_key_label = QLabel("SSH Private Key") 67 | self.ssh_key_input = QTextEdit() 68 | self.ssh_key_input.setPlaceholderText("Paste your SSH private key here (must start with -----BEGIN ... PRIVATE KEY-----)") 69 | layout.addWidget(ssh_key_label) 70 | layout.addWidget(self.ssh_key_input) 71 | 72 | # SSH Password (optional) 73 | password_layout = QHBoxLayout() 74 | password_label = QLabel("SSH Password (leave blank to use private key)") 75 | self.password_input = QLineEdit() 76 | self.password_input.setEchoMode(QLineEdit.Password) 77 | password_layout.addWidget(password_label) 78 | password_layout.addWidget(self.password_input) 79 | layout.addLayout(password_layout) 80 | 81 | # Buttons 82 | button_layout = QHBoxLayout() 83 | save_button = QPushButton("Save") 84 | save_button.clicked.connect(self.save_settings) 85 | cancel_button = QPushButton("Cancel") 86 | cancel_button.clicked.connect(self.reject) 87 | button_layout.addWidget(save_button) 88 | button_layout.addWidget(cancel_button) 89 | layout.addLayout(button_layout) 90 | 91 | # Set dialog properties 92 | self.setMinimumWidth(500) 93 | self.setStyleSheet(""" 94 | QDialog { 95 | background-color: #1a1a1a; 96 | } 97 | QLabel { 98 | color: #FFFFFF; 99 | } 100 | QLineEdit, QTextEdit { 101 | background-color: #2d2d2d; 102 | color: #FFFFFF; 103 | border: 1px solid #3d3d3d; 104 | border-radius: 4px; 105 | padding: 4px; 106 | } 107 | QPushButton { 108 | background-color: #2d2d2d; 109 | color: #FFFFFF; 110 | border: none; 111 | padding: 8px 16px; 112 | border-radius: 4px; 113 | } 114 | QPushButton:hover { 115 | background-color: #3d3d3d; 116 | } 117 | """) 118 | 119 | def browse_local_path(self): 120 | """Open folder selection dialog""" 121 | folder = QFileDialog.getExistingDirectory( 122 | self, 123 | "Select Local Sync Folder", 124 | self.local_path_input.text() 125 | ) 126 | if folder: 127 | self.local_path_input.setText(folder) 128 | 129 | def load_settings(self): 130 | """Load current settings""" 131 | ssh_settings = self.config.get_ssh_settings() 132 | sync_settings = self.config.get_sync_settings() 133 | 134 | self.local_path_input.setText(sync_settings.get('local_path', '')) 135 | self.hostname_input.setText(ssh_settings.get('hostname', '')) 136 | self.username_input.setText(ssh_settings.get('username', '')) 137 | self.remote_path_input.setText(ssh_settings.get('remote_path', '')) 138 | self.ssh_key_input.setText(ssh_settings.get('ssh_key', '')) 139 | self.password_input.setText(ssh_settings.get('password', '')) 140 | 141 | def save_settings(self): 142 | """Save settings""" 143 | ssh_settings = { 144 | 'hostname': self.hostname_input.text(), 145 | 'username': self.username_input.text(), 146 | 'remote_path': self.remote_path_input.text(), 147 | 'ssh_key': self.ssh_key_input.toPlainText(), 148 | 'password': self.password_input.text() 149 | } 150 | 151 | sync_settings = { 152 | 'local_path': self.local_path_input.text(), 153 | 'auto_sync': True, # Default to auto-sync enabled 154 | 'sync_interval': 300 # 5 minutes default 155 | } 156 | 157 | self.config.save_ssh_settings(ssh_settings) 158 | self.config.save_sync_settings(sync_settings) 159 | self.accept() 160 | 161 | def load_stylesheet(self): 162 | """Load the application stylesheet""" 163 | qss_path = get_resource_path("themes/ui.qss") 164 | qss_file = QFile(qss_path) 165 | if qss_file.open(QFile.ReadOnly | QFile.Text): 166 | stylesheet = qss_file.readAll().data().decode() 167 | self.setStyleSheet(stylesheet) 168 | qss_file.close() 169 | else: 170 | print(f"Failed to load stylesheet from {qss_path}") -------------------------------------------------------------------------------- /ui/widgets/tray_icon.py: -------------------------------------------------------------------------------- 1 | from PySide6.QtWidgets import QSystemTrayIcon, QMenu 2 | from PySide6.QtGui import QIcon 3 | from PySide6.QtCore import Signal 4 | import os 5 | import sys 6 | 7 | def get_resource_path(relative_path): 8 | """Get absolute path to resource, works for dev and for PyInstaller""" 9 | try: 10 | 11 | base_path = sys._MEIPASS 12 | except Exception: 13 | base_path = os.path.abspath(".") 14 | return os.path.join(base_path, relative_path) 15 | 16 | class SystemTrayIcon(QSystemTrayIcon): 17 | quit_requested = Signal() 18 | sync_requested = Signal() 19 | settings_requested = Signal() 20 | 21 | def __init__(self, parent=None): 22 | super().__init__(parent) 23 | icon_path = get_resource_path("assets/icons/app.ico") 24 | self.setIcon(QIcon(icon_path)) 25 | self.setToolTip("GOSync - File Synchronization") 26 | self.setup_menu() 27 | self.activated.connect(self._on_activated) 28 | self.show() 29 | 30 | def setup_menu(self): 31 | """Setup tray icon context menu""" 32 | menu = QMenu() 33 | 34 | # Show action 35 | show_action = menu.addAction("Show") 36 | show_action.triggered.connect(self.parent().show) 37 | 38 | # Sync action 39 | sync_action = menu.addAction("Sync Now") 40 | sync_action.triggered.connect(self.sync_requested.emit) 41 | 42 | # Settings action 43 | settings_action = menu.addAction("Settings") 44 | settings_action.triggered.connect(self.settings_requested.emit) 45 | 46 | menu.addSeparator() 47 | 48 | # Quit action 49 | quit_action = menu.addAction("Quit") 50 | quit_action.triggered.connect(self.quit_requested.emit) 51 | 52 | self.setContextMenu(menu) 53 | 54 | def _on_activated(self, reason): 55 | """Handle tray icon activation""" 56 | if reason == QSystemTrayIcon.ActivationReason.DoubleClick: 57 | self.parent().show() 58 | self.parent().raise_() 59 | self.parent().activateWindow() 60 | 61 | def show_message(self, title, message): 62 | """Show tray notification""" 63 | self.showMessage( 64 | title, 65 | message, 66 | QSystemTrayIcon.MessageIcon.Information, 67 | 2000 68 | ) -------------------------------------------------------------------------------- /ui/windows/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/windows/main_window.py: -------------------------------------------------------------------------------- 1 | from PySide6.QtWidgets import ( 2 | QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, 3 | QPushButton, QLabel, QFileDialog, QStatusBar, 4 | QMenu, QMessageBox, QProgressBar, QApplication 5 | ) 6 | from PySide6.QtCore import Qt, QFile 7 | from PySide6.QtGui import QIcon 8 | from ui.widgets.file_list_widget import FileListWidget 9 | from ui.widgets.settings_dialog import SettingsDialog 10 | from ui.widgets.tray_icon import SystemTrayIcon 11 | from core.sync.sync_manager import SyncManager 12 | from core.ssh.file_transfer import FileTransferManager 13 | import logging 14 | import os 15 | import sys 16 | from pathlib import Path 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | def get_resource_path(relative_path): 21 | """Get absolute path to resource, works for dev and for PyInstaller""" 22 | try: 23 | # PyInstaller creates a temp folder and stores path in _MEIPASS 24 | base_path = sys._MEIPASS 25 | except Exception: 26 | base_path = os.path.abspath(".") 27 | return os.path.join(base_path, relative_path) 28 | 29 | class MainWindow(QMainWindow): 30 | def __init__(self, config, parent=None): 31 | super().__init__(parent) 32 | self.config = config 33 | self.sync_manager = SyncManager(config) 34 | self.file_transfer = None # Will be initialized when needed 35 | 36 | self.setWindowTitle("GOSync") 37 | self.load_stylesheet() 38 | self.setup_ui() 39 | self.setup_signals() 40 | self.setup_tray() 41 | 42 | # Auto-start sync if settings exist 43 | self.check_and_start_sync() 44 | 45 | def load_stylesheet(self): 46 | """Load the application stylesheet""" 47 | qss_path = get_resource_path("themes/ui.qss") 48 | qss_file = QFile(qss_path) 49 | if qss_file.open(QFile.ReadOnly | QFile.Text): 50 | stylesheet = qss_file.readAll().data().decode() 51 | self.setStyleSheet(stylesheet) 52 | qss_file.close() 53 | else: 54 | logger.error(f"Failed to load stylesheet from {qss_path}") 55 | 56 | def check_and_start_sync(self): 57 | """Check if settings exist and start sync automatically""" 58 | ssh_settings = self.config.get_ssh_settings() 59 | if (ssh_settings.get('hostname') and 60 | ssh_settings.get('username') and 61 | ssh_settings.get('remote_path') and 62 | (ssh_settings.get('ssh_key') or ssh_settings.get('password'))): 63 | 64 | logger.info("Found existing settings, starting sync automatically") 65 | self.start_sync() 66 | 67 | def setup_ui(self): 68 | # Create central widget and main layout 69 | central_widget = QWidget() 70 | self.setCentralWidget(central_widget) 71 | main_layout = QVBoxLayout(central_widget) 72 | main_layout.setSpacing(10) 73 | main_layout.setContentsMargins(20, 20, 20, 20) 74 | 75 | # Create header 76 | header_layout = QHBoxLayout() 77 | title_label = QLabel("GOSync") 78 | title_label.setObjectName("titleLabel") 79 | header_layout.addWidget(title_label) 80 | header_layout.addStretch() 81 | main_layout.addLayout(header_layout) 82 | 83 | # Create file lists container 84 | lists_layout = QHBoxLayout() 85 | lists_layout.setSpacing(20) 86 | 87 | # Local files 88 | local_container = QWidget() 89 | local_layout = QVBoxLayout(local_container) 90 | local_header = QHBoxLayout() 91 | local_label = QLabel("Local Files") 92 | local_label.setObjectName("sectionLabel") 93 | local_header.addWidget(local_label) 94 | local_header.addStretch() 95 | 96 | self.local_files = FileListWidget() 97 | self.local_files.setContextMenuPolicy(Qt.CustomContextMenu) 98 | self.local_files.customContextMenuRequested.connect(self.show_local_context_menu) 99 | 100 | local_layout.addLayout(local_header) 101 | local_layout.addWidget(self.local_files) 102 | lists_layout.addWidget(local_container) 103 | 104 | # Remote files 105 | remote_container = QWidget() 106 | remote_layout = QVBoxLayout(remote_container) 107 | remote_header = QHBoxLayout() 108 | remote_label = QLabel("Remote Files") 109 | remote_label.setObjectName("sectionLabel") 110 | remote_header.addWidget(remote_label) 111 | remote_header.addStretch() 112 | 113 | self.remote_files = FileListWidget() 114 | self.remote_files.setContextMenuPolicy(Qt.CustomContextMenu) 115 | self.remote_files.customContextMenuRequested.connect(self.show_remote_context_menu) 116 | 117 | remote_layout.addLayout(remote_header) 118 | remote_layout.addWidget(self.remote_files) 119 | lists_layout.addWidget(remote_container) 120 | 121 | main_layout.addLayout(lists_layout) 122 | 123 | # Create bottom buttons 124 | button_layout = QHBoxLayout() 125 | button_layout.setSpacing(10) 126 | 127 | self.sync_button = QPushButton("Sync Now") 128 | self.sync_button.clicked.connect(self.sync_files) 129 | 130 | self.start_sync_button = QPushButton("Start Sync") 131 | self.start_sync_button.clicked.connect(self.start_sync) 132 | 133 | self.stop_sync_button = QPushButton("Stop Sync") 134 | self.stop_sync_button.clicked.connect(self.stop_sync) 135 | self.stop_sync_button.setObjectName("dangerButton") 136 | self.stop_sync_button.setEnabled(False) 137 | 138 | self.settings_button = QPushButton("Settings") 139 | self.settings_button.clicked.connect(self.show_settings) 140 | 141 | button_layout.addWidget(self.sync_button) 142 | button_layout.addWidget(self.start_sync_button) 143 | button_layout.addWidget(self.stop_sync_button) 144 | button_layout.addStretch() 145 | button_layout.addWidget(self.settings_button) 146 | 147 | main_layout.addLayout(button_layout) 148 | 149 | # Create status bar 150 | self.status_bar = QStatusBar() 151 | self.setStatusBar(self.status_bar) 152 | 153 | # Add progress bar to status bar 154 | self.progress_bar = QProgressBar() 155 | self.progress_bar.setFixedWidth(200) 156 | self.progress_bar.setVisible(False) 157 | self.status_bar.addPermanentWidget(self.progress_bar) 158 | 159 | # Set window properties 160 | self.setMinimumSize(1000, 700) 161 | 162 | def setup_signals(self): 163 | """Connect signals from sync manager""" 164 | if self.sync_manager.sync_worker: 165 | self.sync_manager.sync_worker.sync_complete.connect(self.on_sync_complete) 166 | self.sync_manager.sync_worker.sync_progress.connect(self.on_sync_progress) 167 | self.sync_manager.sync_worker.files_updated.connect(self.on_files_updated) 168 | 169 | if self.sync_manager.sync_worker.ssh_client and self.sync_manager.sync_worker.ssh_client.worker: 170 | worker = self.sync_manager.sync_worker.ssh_client.worker 171 | worker.connected.connect(self.on_ssh_connected) 172 | worker.operation_complete.connect(self.on_operation_complete) 173 | worker.operation_progress.connect(self.on_operation_progress) 174 | worker.file_list_ready.connect(self.on_remote_files_updated) 175 | 176 | def sync_files(self): 177 | """Start manual sync""" 178 | self.sync_button.setEnabled(False) 179 | self.start_sync_button.setEnabled(False) 180 | self.stop_sync_button.setEnabled(False) 181 | self.settings_button.setEnabled(False) 182 | self.status_bar.showMessage("Starting synchronization...") 183 | self.sync_manager.sync_now() 184 | 185 | def start_sync(self): 186 | """Start automatic sync""" 187 | self.sync_button.setEnabled(False) 188 | self.start_sync_button.setEnabled(False) 189 | self.stop_sync_button.setEnabled(True) 190 | self.settings_button.setEnabled(True) 191 | self.status_bar.showMessage("Starting automatic synchronization...") 192 | self.sync_manager.start_sync() 193 | self.setup_signals() 194 | 195 | def stop_sync(self): 196 | """Stop automatic sync""" 197 | self.sync_manager.stop_sync() 198 | self.sync_button.setEnabled(True) 199 | self.start_sync_button.setEnabled(True) 200 | self.stop_sync_button.setEnabled(False) 201 | self.settings_button.setEnabled(True) 202 | self.status_bar.showMessage("Synchronization stopped") 203 | 204 | def show_settings(self): 205 | """Show settings dialog""" 206 | dialog = SettingsDialog(self.config, self) 207 | if dialog.exec(): 208 | self.status_bar.showMessage("Settings saved") 209 | # Auto-start sync if it wasn't running 210 | if not self.stop_sync_button.isEnabled(): 211 | self.check_and_start_sync() 212 | 213 | def on_sync_complete(self, success, message): 214 | """Handle sync completion""" 215 | self.sync_button.setEnabled(True) 216 | self.start_sync_button.setEnabled(True) 217 | self.settings_button.setEnabled(True) 218 | self.status_bar.showMessage(message) 219 | 220 | def on_sync_progress(self, message): 221 | """Handle sync progress update""" 222 | self.status_bar.showMessage(message) 223 | 224 | def on_files_updated(self, local_files, remote_files): 225 | """Update file lists""" 226 | self.local_files.update_files(local_files) 227 | self.remote_files.update_files(remote_files) 228 | 229 | def on_ssh_connected(self, success, message): 230 | """Handle SSH connection status""" 231 | if not success: 232 | self.stop_sync() 233 | self.status_bar.showMessage(message) 234 | 235 | def on_operation_complete(self, success, message): 236 | """Handle SSH operation completion""" 237 | if not success: 238 | self.status_bar.showMessage(f"Error: {message}", 5000) 239 | else: 240 | self.status_bar.showMessage(message, 3000) 241 | 242 | def on_operation_progress(self, message): 243 | """Handle SSH operation progress""" 244 | self.status_bar.showMessage(message) 245 | 246 | def on_remote_files_updated(self, files): 247 | """Update remote files list""" 248 | self.remote_files.update_files(files) 249 | 250 | def show_local_context_menu(self, position): 251 | """Show context menu for local files""" 252 | menu = QMenu() 253 | upload_action = menu.addAction("Upload to Server") 254 | delete_action = menu.addAction("Delete") 255 | 256 | action = menu.exec_(self.local_files.mapToGlobal(position)) 257 | if action == upload_action: 258 | self.upload_selected_files() 259 | elif action == delete_action: 260 | self.delete_local_files() 261 | 262 | def show_remote_context_menu(self, position): 263 | """Show context menu for remote files""" 264 | selected_items = self.remote_files.selectedItems() 265 | if not selected_items: 266 | return 267 | 268 | menu = QMenu() 269 | download_action = menu.addAction("Download to Local") 270 | refresh_action = menu.addAction("Refresh File List") 271 | 272 | action = menu.exec_(self.remote_files.mapToGlobal(position)) 273 | if action == download_action: 274 | self.download_selected_files() 275 | elif action == refresh_action: 276 | self.sync_manager.sync_now() # Refresh the file list 277 | 278 | def download_selected_files(self): 279 | """Download selected files from remote server""" 280 | selected_items = self.remote_files.selectedItems() 281 | if not selected_items: 282 | return 283 | 284 | # Initialize file transfer manager if needed 285 | if not self.file_transfer: 286 | self.file_transfer = FileTransferManager(self.sync_manager.sync_worker.ssh_client) 287 | self.file_transfer.transfer_progress.connect(self.on_transfer_progress) 288 | self.file_transfer.transfer_complete.connect(self.on_transfer_complete) 289 | 290 | sync_settings = self.config.get_sync_settings() 291 | local_path = Path(sync_settings['local_path']) 292 | 293 | # Get remote base path 294 | ssh_settings = self.config.get_ssh_settings() 295 | remote_base = Path(ssh_settings['remote_path']) 296 | 297 | for item in selected_items: 298 | try: 299 | remote_file = item.text() 300 | local_file = local_path / remote_file 301 | 302 | # Construct full remote path 303 | full_remote_path = str(remote_base / remote_file).replace('\\', '/') 304 | logger.info(f"Attempting to download: {full_remote_path}") 305 | 306 | # Start download 307 | self.progress_bar.setVisible(True) 308 | self.progress_bar.setRange(0, 0) # Indeterminate progress 309 | self.file_transfer.download_file(full_remote_path, local_file) 310 | 311 | except Exception as e: 312 | logger.error(f"Error downloading {remote_file}: {str(e)}") 313 | QMessageBox.warning( 314 | self, 315 | "Download Error", 316 | f"Failed to download '{remote_file}': {str(e)}", 317 | QMessageBox.Ok 318 | ) 319 | 320 | def upload_selected_files(self): 321 | """Upload selected files to server""" 322 | selected_items = self.local_files.selectedItems() 323 | if not selected_items: 324 | return 325 | 326 | # Initialize file transfer manager if needed 327 | if not self.file_transfer: 328 | self.file_transfer = FileTransferManager(self.sync_manager.sync_worker.ssh_client) 329 | self.file_transfer.transfer_progress.connect(self.on_transfer_progress) 330 | self.file_transfer.transfer_complete.connect(self.on_transfer_complete) 331 | 332 | sync_settings = self.config.get_sync_settings() 333 | local_path = Path(sync_settings['local_path']) 334 | ssh_settings = self.config.get_ssh_settings() 335 | remote_base = ssh_settings['remote_path'] 336 | 337 | for item in selected_items: 338 | local_file = local_path / item.text() 339 | remote_file = os.path.join(remote_base, item.text()).replace('\\', '/') 340 | 341 | # Start upload 342 | self.progress_bar.setVisible(True) 343 | self.progress_bar.setRange(0, 0) # Indeterminate progress 344 | self.file_transfer.upload_file(local_file, remote_file) 345 | 346 | def on_transfer_progress(self, message): 347 | """Handle file transfer progress""" 348 | self.status_bar.showMessage(message) 349 | 350 | def on_transfer_complete(self, success, message): 351 | """Handle file transfer completion""" 352 | self.progress_bar.setVisible(False) 353 | self.status_bar.showMessage(message, 5000 if success else 10000) 354 | 355 | if success: 356 | # Refresh file lists 357 | self.sync_manager.sync_now() 358 | 359 | def delete_local_files(self): 360 | """Delete selected local files""" 361 | selected_items = self.local_files.selectedItems() 362 | if not selected_items: 363 | return 364 | 365 | reply = QMessageBox.question( 366 | self, 367 | "Confirm Delete", 368 | f"Are you sure you want to delete {len(selected_items)} file(s)?", 369 | QMessageBox.Yes | QMessageBox.No, 370 | QMessageBox.No 371 | ) 372 | 373 | if reply == QMessageBox.Yes: 374 | sync_settings = self.config.get_sync_settings() 375 | local_path = sync_settings['local_path'] 376 | 377 | for item in selected_items: 378 | file_path = os.path.join(local_path, item.text()) 379 | try: 380 | os.remove(file_path) 381 | logger.info(f"Deleted local file: {file_path}") 382 | except Exception as e: 383 | logger.error(f"Failed to delete {file_path}: {str(e)}") 384 | 385 | def setup_tray(self): 386 | """Setup system tray icon""" 387 | self.tray_icon = SystemTrayIcon(self) 388 | self.tray_icon.sync_requested.connect(self.sync_files) 389 | self.tray_icon.settings_requested.connect(self.show_settings) 390 | self.tray_icon.quit_requested.connect(self.quit_application) 391 | 392 | def closeEvent(self, event): 393 | """Handle window close event""" 394 | if self.tray_icon.isVisible(): 395 | self.hide() 396 | self.tray_icon.show_message( 397 | "GOSync", 398 | "Application is still running in the background." 399 | ) 400 | event.ignore() 401 | else: 402 | self.quit_application() 403 | 404 | def quit_application(self): 405 | """Quit the application properly""" 406 | self.sync_manager.stop_sync() 407 | self.tray_icon.hide() 408 | self.close() 409 | QApplication.quit() -------------------------------------------------------------------------------- /utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /utils/config.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from pathlib import Path 4 | 5 | class Config: 6 | def __init__(self): 7 | self.config_dir = Path.home() / '.gosync' 8 | self.config_file = self.config_dir / 'config.json' 9 | self.ensure_config_dir() 10 | self.load_config() 11 | 12 | def ensure_config_dir(self): 13 | """Ensure the config directory exists""" 14 | self.config_dir.mkdir(parents=True, exist_ok=True) 15 | 16 | def load_config(self): 17 | """Load configuration from file""" 18 | if self.config_file.exists(): 19 | with open(self.config_file, 'r') as f: 20 | self.config = json.load(f) 21 | else: 22 | self.config = { 23 | 'ssh': { 24 | 'hostname': '', 25 | 'username': '', 26 | 'remote_path': '', 27 | 'ssh_key': '', 28 | 'password': '' 29 | }, 30 | 'sync': { 31 | 'local_path': str(Path.home() / 'SyncerSync'), 32 | 'auto_sync': False, 33 | 'sync_interval': 300 # 5 minutes 34 | } 35 | } 36 | self.save_config() 37 | 38 | def save_config(self): 39 | """Save configuration to file""" 40 | with open(self.config_file, 'w') as f: 41 | json.dump(self.config, f, indent=4) 42 | 43 | def get_ssh_settings(self): 44 | """Get SSH connection settings""" 45 | return self.config.get('ssh', {}) 46 | 47 | def save_ssh_settings(self, settings): 48 | """Save SSH connection settings""" 49 | self.config['ssh'] = settings 50 | self.save_config() 51 | 52 | def get_sync_settings(self): 53 | """Get synchronization settings""" 54 | return self.config.get('sync', {}) 55 | 56 | def save_sync_settings(self, settings): 57 | """Save synchronization settings""" 58 | self.config['sync'] = settings 59 | self.save_config() -------------------------------------------------------------------------------- /utils/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | from pathlib import Path 4 | import codecs 5 | 6 | def setup_logger(): 7 | 8 | 9 | log_dir = Path.home() / '.gosync' / 'logs' 10 | log_dir.mkdir(parents=True, exist_ok=True) 11 | log_file = log_dir / 'gosync.log' 12 | 13 | 14 | logging.basicConfig( 15 | level=logging.INFO, 16 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', 17 | handlers=[ 18 | logging.FileHandler(log_file, encoding='utf-8'), 19 | logging.StreamHandler(codecs.getwriter('utf-8')(sys.stdout.buffer)) 20 | ] 21 | ) 22 | 23 | 24 | logger = logging.getLogger('GOSync') 25 | logger.setLevel(logging.INFO) 26 | 27 | return logger --------------------------------------------------------------------------------