├── .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 |
3 |
4 |
5 |
6 |
7 | [](LICENSE)
8 | [](https://www.python.org)
9 | [](https://github.com/yourusername/GOSync)
10 | [](https://wiki.qt.io/Qt_for_Python)
11 | [](https://github.com/yourusername/GOSync)
12 | [](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 |
58 |
59 | Main interface showing local and remote file synchronization panels
60 |
61 |
62 | ### Settings Dialog
63 |
64 |
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
--------------------------------------------------------------------------------