commit 8319916a06531b9ba82bce7f82a12b582160f5f1 Author: wwartana Date: Thu Jan 29 23:07:55 2026 +0800 feat: Introduce initial application structure, SQL database tools, and license management. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea03cbd --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / Build +build/ +dist/ +# *.spec # Keep spec file for reproducible builds + +# IDE / Editors +.vscode/ +.idea/ +.gemini/ + +# Project Specific - Sensitive Data +config.dat +config.key +license.dat + +# Large Binaries / Tools +# sql_tools/ # Diizinkan untuk distribusi +sql_tools_zip/ + +# Backup Files +*.sql +*.zip +*.rar +*.bak + +# Logs +*.log diff --git a/Keygen.spec b/Keygen.spec new file mode 100644 index 0000000..1a37843 --- /dev/null +++ b/Keygen.spec @@ -0,0 +1,36 @@ +# -*- mode: python ; coding: utf-8 -*- + +block_cipher = None + +a = Analysis( + ['keygen.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, +) +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name='KeyProBackup', # Name of the executable + debug=False, + bootloader_ignore_signals=False, + strip=False, # Important for XP + upx=False, + upx_exclude=[], + runtime_tmpdir=None, + console=False, # GUI Mode + icon='icon.ico', +) diff --git a/ProBackup.spec b/ProBackup.spec new file mode 100644 index 0000000..a2f79cf --- /dev/null +++ b/ProBackup.spec @@ -0,0 +1,108 @@ +# -*- mode: python ; coding: utf-8 -*- +import os +import sys +import PyQt5 + +# --- MONKEYPATCH PyInstaller QT Detection (Force correct path) --- +try: + from PyInstaller.utils.hooks import qt + + # 1. Find the real plugin path + qt5_dir = os.path.dirname(PyQt5.__file__) + possible_plugin_paths = [ + os.path.join(qt5_dir, 'Qt', 'plugins'), + os.path.join(qt5_dir, 'plugins'), + ] + real_plugin_path = None + for p in possible_plugin_paths: + if os.path.exists(p): + real_plugin_path = p + break + + # 2. Override the failing function + if real_plugin_path: + print(">>> FORCING QT PLUGINS PATH: {}".format(real_plugin_path)) + + # Save original just in case, though we ignore it + original_qt_plugins_dir = qt.qt_plugins_dir + + def new_qt_plugins_dir(namespace=None): + return real_plugin_path + + qt.qt_plugins_dir = new_qt_plugins_dir + + # Also set env var as backup + os.environ['QT_PLUGIN_PATH'] = real_plugin_path + else: + print(">>> ERROR: Could not find PyQt5 plugins to monkeypatch!") + +except ImportError: + print(">>> WARNING: Could not import PyInstaller.utils.hooks.qt for patching") +except Exception as e: + print(">>> WARNING: Monkeypatch failed: {}".format(e)) +# ----------------------------------------------------------------------------- + +block_cipher = None + +a = Analysis( + ['main.py'], + pathex=[], + binaries=[], + # Bundle tools to sql_tools subfolder + datas=[ + ('icon.png', '.'), + ('sql_tools/pg_dump.exe', 'sql_tools'), + ('sql_tools/mysqldump.exe', 'sql_tools') + ], + hiddenimports=['PyQt5', 'pymysql', 'psycopg2', 'apscheduler'], + hookspath=[], + runtime_hooks=['rthook_qt_fix.py'], + # Context7 Optimization: Exclude unused modules to reduce size + excludes=['FixTk', 'tcl', 'tk', '_tkinter', 'tkinter', 'Tkinter', 'matplotlib', 'numpy', 'scipy', 'pandas', 'PIL.ImageTk'], + win_no_prefer_redirects=False, + cipher=block_cipher, +) + +# --- MANUAL BUNDLING OF PLATFORMS (Fix "platform plugin windows not found") --- +# Re-locate the plugins directory (redundant but safe) +qt5_dir = os.path.dirname(PyQt5.__file__) +plugin_sources = [ + os.path.join(qt5_dir, 'Qt', 'plugins', 'platforms'), + os.path.join(qt5_dir, 'plugins', 'platforms'), +] +found_plugin = None +for p in plugin_sources: + if os.path.exists(p): + found_plugin = p + break + +if found_plugin: + print(">>> MANUALLY BUNDLING PLATFORMS FROM: {}".format(found_plugin)) + a.datas += Tree(found_plugin, prefix='platforms') # Bundle as root/platforms/ + + # Also bundle as root/qt/plugins/platforms just to match common fallback paths + # a.datas += Tree(found_plugin, prefix='qt/plugins/platforms') +else: + print(">>> ERROR: Could not find 'platforms' folder for bundling!") +# ----------------------------------------------------------------------------- + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +# ONEFILE Mode: Everything bundled into single EXE +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name='ProBackup', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + icon='icon.ico', +) diff --git a/PyQt5-5.5-gpl-Py3.4-Qt5.5.0-x32.exe b/PyQt5-5.5-gpl-Py3.4-Qt5.5.0-x32.exe new file mode 100644 index 0000000..1691f59 Binary files /dev/null and b/PyQt5-5.5-gpl-Py3.4-Qt5.5.0-x32.exe differ diff --git a/backup_manager.py b/backup_manager.py new file mode 100644 index 0000000..757fa69 --- /dev/null +++ b/backup_manager.py @@ -0,0 +1,679 @@ +import os +import sys +import shutil +import zipfile +import time +import datetime +import subprocess +from abc import ABC, abstractmethod + +# Try importing DB libraries, but don't fail if they aren't installed yet +# (UI might just disable those options or show error on run) +try: + import pymysql +except ImportError: + pymysql = None + +try: + import psycopg2 +except ImportError: + psycopg2 = None + + +def get_mysql_info(host, port, user, password, skip_ssl=True): + """ + Connect to MySQL server and get version + database list. + + Returns: + tuple: (version_string, [database_list]) or (None, []) on error + """ + if pymysql is None: + return None, [], "pymysql library not installed" + + try: + # Build connection kwargs + conn_kwargs = { + 'host': host, + 'port': int(port), + 'user': user, + 'password': password, + 'connect_timeout': 10, + } + + # Add SSL settings + if skip_ssl: + conn_kwargs['ssl'] = None + conn_kwargs['ssl_disabled'] = True + + conn = pymysql.connect(**conn_kwargs) + cursor = conn.cursor() + + # Get version + cursor.execute("SELECT VERSION()") + version = cursor.fetchone()[0] + + # Get database list + cursor.execute("SHOW DATABASES") + databases = [row[0] for row in cursor.fetchall()] + + # Filter out system databases + system_dbs = ['information_schema', 'performance_schema', 'mysql', 'sys'] + user_dbs = [db for db in databases if db.lower() not in system_dbs] + + cursor.close() + conn.close() + + return version, user_dbs, None + except Exception as e: + return None, [], str(e) + + +def get_postgres_info(host, port, user, password): + """ + Connect to PostgreSQL server and get version + database list. + + Returns: + tuple: (version_string, [database_list], error_msg) or (None, [], error) on error + """ + if psycopg2 is None: + return None, [], "psycopg2 library not installed" + + try: + conn = psycopg2.connect( + host=host, + port=int(port), + user=user, + password=password, + dbname='postgres', # Connect to default db first + connect_timeout=10 + ) + cursor = conn.cursor() + + # Get version + cursor.execute("SELECT version()") + full_version = cursor.fetchone()[0] + # Extract just version number (e.g., "PostgreSQL 15.2") + version = full_version.split(',')[0] if ',' in full_version else full_version.split(' on ')[0] + + # Get database list + cursor.execute("SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname") + databases = [row[0] for row in cursor.fetchall()] + + # Filter out system databases + system_dbs = ['postgres'] + user_dbs = [db for db in databases if db.lower() not in system_dbs] + + cursor.close() + conn.close() + + return version, user_dbs, None + except Exception as e: + return None, [], str(e) + + +def format_size(size_bytes): + """Format bytes to human readable size.""" + if size_bytes < 1024: + return "{} B".format(size_bytes) + elif size_bytes < 1024 * 1024: + return "{:.1f} KB".format(size_bytes / 1024) + elif size_bytes < 1024 * 1024 * 1024: + return "{:.2f} MB".format(size_bytes / (1024 * 1024)) + else: + return "{:.2f} GB".format(size_bytes / (1024 * 1024 * 1024)) + + +def find_sql_tool(tool_name, server_type=None, server_version=None): + """ + Find SQL tool executable with version support. + + Priority: + 1. Exact match in sql_tools/{type}/{version}/tool.exe + 2. Best match (same major version) in sql_tools/{type}/ + 3. Root sql_tools/tool.exe (legacy/generic) + 4. Bundled sql_tools + + Args: + tool_name: e.g., 'mysqldump.exe' + server_type: 'mysql', 'mariadb', 'postgres' + server_version: e.g., '10.6.12', '8.0.32' + """ + # Define tool variants + tool_variants = { + 'mysqldump.exe': ['mysqldump.exe', 'mariadb-dump.exe', 'mariadump.exe', 'mysql_dump.exe'], + 'pg_dump.exe': ['pg_dump.exe', 'pgdump.exe'], + 'pg_dumpall.exe': ['pg_dumpall.exe', 'pgdumpall.exe'] + } + + names_to_search = tool_variants.get(tool_name, [tool_name]) + + # Base directories + if getattr(sys, 'frozen', False): + base_dir = os.path.dirname(sys.executable) + bundle_dir = getattr(sys, '_MEIPASS', os.path.dirname(sys.executable)) + else: + base_dir = os.path.dirname(os.path.abspath(__file__)) + bundle_dir = base_dir + + search_roots = [ + os.path.join(base_dir, 'sql_tools'), + os.path.join(bundle_dir, 'sql_tools') + ] + + # Helper to check file existence + def check_exists(path): + for name in names_to_search: + full_path = os.path.join(path, name) + if os.path.exists(full_path): + return full_path + return None + + # 1. Version Specific Search + if server_type and server_version: + import re + # Extract major.minor (e.g. 8.0 from 8.0.33) + v_match = re.match(r'(\d+\.\d+)', str(server_version)) + if v_match: + short_ver = v_match.group(1) + + for root in search_roots: + # Try exact version folder: sql_tools/mysql/8.0/ + version_path = os.path.join(root, server_type, short_ver) + found = check_exists(version_path) + if found: return found + + # Try closest match (simplified: just list dirs and find same major) + # e.g. looking for 8.0, but only 8.4 is there -> might be okay for upgrade? + # Better: exact match first. If not found, look for newer compatible? + # For now let's stick to simple fallbacks if specific version not found. + + # 2. Root Search (Legacy/Fallback) + for root in search_roots: + found = check_exists(root) + if found: return found + + return None + + +class BackupStrategy(ABC): + def __init__(self, update_signal=None, log_signal=None): + self.update_signal = update_signal + self.log_signal = log_signal + self._cancel_flag = False + + def log(self, message): + if self.log_signal: + timestamp = datetime.datetime.now().strftime("%H:%M:%S") + self.log_signal.emit("[{}] {}".format(timestamp, message)) + else: + print(message) + + def progress(self, value): + if self.update_signal: + self.update_signal.emit(value) + + def cancel(self): + self._cancel_flag = True + self.log("Pembatalan (diminta/tertunda)...") + + def is_cancelled(self): + return self._cancel_flag + + def cleanup_old_backups(self, dest, retention_days): + if retention_days <= 0: + return + + self.log("Menjalankan pembersihan (Retensi: {} hari)...".format(retention_days)) + now = time.time() + cutoff = now - (retention_days * 86400) + + if not os.path.exists(dest): + return + + count = 0 + try: + for filename in os.listdir(dest): + filepath = os.path.join(dest, filename) + if os.path.isfile(filepath): + # Check modified time or created time + file_time = os.path.getmtime(filepath) + if file_time < cutoff: + try: + os.remove(filepath) + self.log("Menghapus backup lama: {}".format(filename)) + count += 1 + except Exception as e: + self.log("Gagal menghapus {}: {}".format(filename, e)) + except Exception as e: + self.log("Error saat pembersihan: {}".format(e)) + + if count > 0: + self.log("Pembersihan selesai. Menghapus {} file lama.".format(count)) + else: + self.log("Pembersihan selesai. Tidak ada file lama ditemukan.") + + @abstractmethod + def run_backup(self, config): + pass + + +class FileBackup(BackupStrategy): + def run_backup(self, config): + source = config.get('source') + dest = config.get('dest') + archive = config.get('archive', False) + + if not os.path.exists(source): + raise FileNotFoundError("Sumber tidak ditemukan: {}".format(source)) + + if not os.path.exists(dest): + os.makedirs(dest, exist_ok=True) + + self.log("Memulai Backup File: {} -> {}".format(source, dest)) + + name = os.path.basename(source.rstrip(os.sep)) + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + + if archive: + # ZIP MODE + zip_filename = "{}_{}.zip".format(name, timestamp) + zip_path = os.path.join(dest, zip_filename) + self.log("Membuat arsip: {}".format(zip_path)) + + # Count total files for progress + total_files = 0 + for root, dirs, files in os.walk(source): + total_files += len(files) + + processed_files = 0 + with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf: + if os.path.isfile(source): + zf.write(source, os.path.basename(source)) + self.progress(100) + else: + for root, dirs, files in os.walk(source): + if self.is_cancelled(): + self.log("Backup dibatalkan.") + return + for file in files: + file_path = os.path.join(root, file) + arcname = os.path.relpath(file_path, os.path.dirname(source)) + zf.write(file_path, arcname) + + processed_files += 1 + if total_files > 0: + self.progress(int(processed_files / total_files * 100)) + + self.log("Arsip berhasil dibuat.") + + else: + # COPY MODE + dest_path = os.path.join(dest, "{}_{}".format(name, timestamp)) + self.log("Menyalin ke: {}".format(dest_path)) + + if os.path.isfile(source): + shutil.copy2(source, dest_path) + self.progress(100) + else: + # Initial implementation: simple copytree + # For better progress, we'd need to walk and copy manually like Zip + # But copytree is robust. Let's do a manual walk for progress. + total_files = 0 + for root, dirs, files in os.walk(source): + total_files += len(files) + + processed_files = 0 + + # Re-walk to copy + # We need to replicate directory structure first + # Or just use shutil.copytree but we lose granular progress + # Let's implement manual walk for progress + + + for root, dirs, files in os.walk(source): + if self.is_cancelled(): + self.log("Backup dibatalkan.") + return + + # Compute destination directory + rel_path = os.path.relpath(root, source) + current_dest_dir = os.path.join(dest_path, rel_path) + os.makedirs(current_dest_dir, exist_ok=True) + + for file in files: + src_file = os.path.join(root, file) + dst_file = os.path.join(current_dest_dir, file) + shutil.copy2(src_file, dst_file) + + processed_files += 1 + if total_files > 0: + self.progress(int(processed_files / total_files * 100)) + + self.log("Penyalinan berhasil diselesaikan.") + + # Cleanup + self.cleanup_old_backups(dest, config.get('retention_days', 0)) + self.progress(100) + + +class MySQLBackup(BackupStrategy): + def run_backup(self, config): + host = config.get('host', 'localhost') + port = str(config.get('port', 3306)) + user = config.get('user', 'root') + password = config.get('password', '') + database = config.get('database') + dest = config.get('dest') + all_databases = config.get('all_databases', False) + skip_ssl = config.get('skip_ssl', True) + + # Detect Server Version + self.log("Mendeteksi versi server MySQL di {}:{}...".format(host, port)) + version, _, error = get_mysql_info(host, port, user, password, skip_ssl) + + server_type = 'mysql' # Default + server_version = None + + if error: + self.log("Warning: Gagal mendeteksi versi server ({}). Menggunakan tool default.".format(error)) + else: + self.log("Versi server terdeteksi: {}".format(version)) + server_version = version # Pass full string, find_sql_tool handles parsing + # Check if MariaDB + if 'MariaDB' in version or 'mariadb' in version.lower(): + server_type = 'mariadb' + + # Find mysqldump: Priority sql_tools (custom) > sql_tools (bundled) + mysqldump_path = find_sql_tool('mysqldump.exe', server_type, server_version) + + if not mysqldump_path: + raise FileNotFoundError( + "mysqldump.exe tidak ditemukan!\n" + "Letakkan di folder 'sql_tools' di samping ProBackup.exe\n" + "atau pastikan sql_tools ada di dalam bundle.") + + if not os.path.exists(dest): + os.makedirs(dest, exist_ok=True) + + # Log tool location for debugging + self.log("Menggunakan mysqldump: {}".format(mysqldump_path)) + + # Determine backup mode + if all_databases: + self.log("Menghubungkan ke MySQL: {}:{} (SEMUA DATABASE)".format(host, port)) + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + dump_file_path = os.path.join(dest, "all_databases_{}.sql".format(timestamp)) + else: + self.log("Menghubungkan ke MySQL: {}:{} / {}".format(host, port, database)) + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + dump_file_path = os.path.join(dest, "{}_{}.sql".format(database, timestamp)) + + # Get skip_ssl setting (default True for backward compatibility) + skip_ssl = config.get('skip_ssl', True) + + # Command Construction - build properly from start + cmd = [mysqldump_path, '-h', host, '-P', port, '-u', user] + + # Add password if provided + if password: + cmd.append('--password={}'.format(password)) + + # Add skip-ssl if enabled - detect tool type for correct option + if skip_ssl: + tool_basename = os.path.basename(mysqldump_path).lower() + if 'mariadb' in tool_basename or 'mariadump' in tool_basename: + # MariaDB uses --ssl=0 or --skip-ssl depending on version + cmd.append('--ssl=0') + self.log("Menggunakan opsi SSL MariaDB: --ssl=0") + else: + # MySQL uses --skip-ssl + cmd.append('--skip-ssl') + self.log("Menggunakan opsi SSL MySQL: --skip-ssl") + + # Add remaining options + cmd.extend([ + '--quick', + '--lock-tables=false', + '--result-file=' + dump_file_path, + ]) + + # Add database or all-databases flag + if all_databases: + cmd.append('--all-databases') + self.log("Mode: Backup semua database (--all-databases)") + else: + cmd.append(database) + + self.log("Menjalankan mysqldump ke: {}".format(dump_file_path)) + self.log("Mohon tunggu...") + + try: + # Hide password warning on stderr + # Using Popen to handle cancellation + # Hide console window on Windows + startupinfo = None + if sys.platform == 'win32': + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + startupinfo.wShowWindow = subprocess.SW_HIDE + + process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, startupinfo=startupinfo) + + # Monitor file size progress + last_log_time = time.time() + log_interval = 2 # Log every 2 seconds + + while True: + retcode = process.poll() + if retcode is not None: + break + + if self.is_cancelled(): + process.terminate() + self.log("Backup dibatalkan.") + if os.path.exists(dump_file_path): + os.remove(dump_file_path) + return + + # Log file size progress + current_time = time.time() + if current_time - last_log_time >= log_interval: + if os.path.exists(dump_file_path): + file_size = os.path.getsize(dump_file_path) + self.log("Progress: {} written...".format(format_size(file_size))) + last_log_time = current_time + + time.sleep(0.5) + + stdout, stderr = process.communicate() + + if retcode != 0: + # mysqldump writes "Using a password..." to stderr as warning, not error. + # We need to distinguish real errors. + # Usually exit code 0 is success. + raise Exception("mysqldump error (Code {}): {}".format(retcode, stderr)) + + # Log final size + if os.path.exists(dump_file_path): + final_size = os.path.getsize(dump_file_path) + self.log("Dump selesai. Total: {}".format(format_size(final_size))) + else: + self.log("Dump selesai.") + self.progress(80) + + # Compress if requested + if config.get('compress', False): + zip_path = dump_file_path.replace('.sql', '.zip') + self.log("Mengompres ke: {}".format(zip_path)) + with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf: + zf.write(dump_file_path, os.path.basename(dump_file_path)) + os.remove(dump_file_path) + self.log("Kompresi selesai.") + + self.log("Backup MySQL berhasil diselesaikan.") + self.progress(100) + self.cleanup_old_backups(dest, config.get('retention_days', 0)) + + except Exception as e: + self.log("Gagal melakukan backup: {}".format(e)) + raise e + + +class PostgresBackup(BackupStrategy): + def run_backup(self, config): + host = config.get('host', 'localhost') + port = str(config.get('port', 5432)) + user = config.get('user', 'postgres') + password = config.get('password', '') + database = config.get('database') + dest = config.get('dest') + all_databases = config.get('all_databases', False) + + if not os.path.exists(dest): + os.makedirs(dest, exist_ok=True) + + # Detect Server Version + self.log("Mendeteksi versi server Postgres di {}:{}...".format(host, port)) + version, _, error = get_postgres_info(host, port, user, password) + + server_version = None + if error: + self.log("Warning: Gagal mendeteksi versi server ({}). Menggunakan tool default.".format(error)) + else: + self.log("Versi server terdeteksi: {}".format(version)) + server_version = version + + # Determine which tool to use based on all_databases option + if all_databases: + # Use pg_dumpall for all databases + pg_tool_path = find_sql_tool('pg_dumpall.exe', 'postgres', server_version) + if not pg_tool_path: + raise FileNotFoundError( + "pg_dumpall.exe tidak ditemukan!\n" + "Letakkan di folder 'sql_tools' di samping ProBackup.exe\n" + "atau pastikan sql_tools ada di dalam bundle.") + + self.log("Menggunakan pg_dumpall: {}".format(pg_tool_path)) + self.log("Menghubungkan ke Postgres: {}:{} (SEMUA DATABASE)".format(host, port)) + + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + dump_file_path = os.path.join(dest, "all_databases_{}.sql".format(timestamp)) + else: + # Use pg_dump for single database + pg_tool_path = find_sql_tool('pg_dump.exe', 'postgres', server_version) + if not pg_tool_path: + raise FileNotFoundError( + "pg_dump.exe tidak ditemukan!\n" + "Letakkan di folder 'sql_tools' di samping ProBackup.exe\n" + "atau pastikan sql_tools ada di dalam bundle.") + + self.log("Menggunakan pg_dump: {}".format(pg_tool_path)) + self.log("Menghubungkan ke Postgres: {}:{} / {}".format(host, port, database)) + + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + dump_file_path = os.path.join(dest, "{}_{}.sql".format(database, timestamp)) + + # Prepare Environment for Password + env = os.environ.copy() + env['PGPASSWORD'] = password + + # Command Construction + if all_databases: + # pg_dumpall command + cmd = [ + pg_tool_path, + '-h', host, + '-p', port, + '-U', user, + '--no-owner', + '--no-acl', + '-f', dump_file_path, + ] + self.log("Mode: Backup semua database (pg_dumpall)") + else: + # pg_dump command for single database + cmd = [ + pg_tool_path, + '-h', host, + '-p', port, + '-U', user, + '--no-owner', + '--no-acl', + '-F', 'p', # Plain text format + '-f', dump_file_path, + database + ] + + self.log("Menjalankan backup ke: {}".format(dump_file_path)) + self.log("Mohon tunggu, proses ini mungkin memakan waktu...") + + try: + # We use subprocess.run. It blocks, which is okay in a WorkerThread. + # To support cancellation, we ideally should use Popen and poll, but for simplicity/robustness: + # If user cancels in UI, the thread might continue until this returns? + # Or we can use Popen. + + # Hide console window on Windows + startupinfo = None + if sys.platform == 'win32': + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + startupinfo.wShowWindow = subprocess.SW_HIDE + + process = subprocess.Popen(cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, startupinfo=startupinfo) + + # Monitor file size progress + last_log_time = time.time() + log_interval = 2 # Log every 2 seconds + + while True: + retcode = process.poll() + if retcode is not None: + break + + if self.is_cancelled(): + process.terminate() + self.log("Backup dibatalkan (Process Terminated).") + if os.path.exists(dump_file_path): + os.remove(dump_file_path) # Cleanup partial + return + + # Log file size progress + current_time = time.time() + if current_time - last_log_time >= log_interval: + if os.path.exists(dump_file_path): + file_size = os.path.getsize(dump_file_path) + self.log("Progress: {} written...".format(format_size(file_size))) + last_log_time = current_time + + time.sleep(0.5) + + stdout, stderr = process.communicate() + + if retcode != 0: + raise Exception("pg_dump error (Code {}): {}".format(retcode, stderr)) + + # Log final size + if os.path.exists(dump_file_path): + final_size = os.path.getsize(dump_file_path) + self.log("Dump selesai. Total: {}".format(format_size(final_size))) + else: + self.log("Dump selesai.") + self.progress(80) + + # Compress if requested + if config.get('compress', False): + zip_path = dump_file_path.replace('.sql', '.zip') + self.log("Mengompres ke: {}".format(zip_path)) + with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf: + zf.write(dump_file_path, os.path.basename(dump_file_path)) + os.remove(dump_file_path) + self.log("Kompresi selesai.") + + self.log("Backup PostgreSQL berhasil diselesaikan.") + self.progress(100) + self.cleanup_old_backups(dest, config.get('retention_days', 0)) + + except Exception as e: + self.log("Gagal melakukan backup: {}".format(e)) + raise e diff --git a/build_xp.bat b/build_xp.bat new file mode 100644 index 0000000..dcd3f3f --- /dev/null +++ b/build_xp.bat @@ -0,0 +1,4 @@ +pyinstaller --noconfirm --distpath dist-winXP --workpath build-winXP --clean ProBackup.spec +echo. +echo Building Keygen... +pyinstaller --noconfirm --distpath dist-winXP --workpath build-winXP --clean Keygen.spec diff --git a/config_manager.py b/config_manager.py new file mode 100644 index 0000000..27ec92d --- /dev/null +++ b/config_manager.py @@ -0,0 +1,119 @@ +import json +import os +import uuid +import base64 + +CONFIG_FILE = "config.dat" # Obfuscated file + +class ConfigManager: + DEFAULT_JOB = { + "id": "", + "name": "New Backup Job", + "type": 0, # 0: File, 1: MySQL, 2: Postgres + "dest": "", + # File + "source": "", + "zip": True, + # DB + "host": "localhost", + "port": "", + "user": "", + "password": "", + "database": "", + "retention_days": 0 # 0 means forever + } + + DEFAULT_CONFIG = { + "jobs": [], + "schedule_enabled": False, + "schedule_interval": 60, + "schedule_unit": "Menit" + } + + @staticmethod + def create_new_job(): + job = ConfigManager.DEFAULT_JOB.copy() + job["id"] = str(uuid.uuid4()) + return job + + @staticmethod + def load_config(): + # Check for old unencrypted config.json and migrate + if os.path.exists("config.json"): + try: + with open("config.json", 'r') as f: + data = json.load(f) + print("Migrasi config.json...") + config = ConfigManager._migrate_old_config(data) + ConfigManager.save_config(config) + os.rename("config.json", "config.json.bak") + return config + except Exception as e: + print("Error migrasi config: {}".format(e)) + + if not os.path.exists(CONFIG_FILE): + return ConfigManager.DEFAULT_CONFIG.copy() + + try: + with open(CONFIG_FILE, 'rb') as f: + encoded_data = f.read() + + # Simple Base64 Decode + decoded_data = base64.b64decode(encoded_data) + data = json.loads(decoded_data.decode('utf-8')) + + config = ConfigManager.DEFAULT_CONFIG.copy() + config.update(data) + return config + except Exception as e: + print("Error loading config: {}".format(e)) + return ConfigManager.DEFAULT_CONFIG.copy() + + @staticmethod + def _migrate_old_config(data): + """Migrate old config format to new format""" + config = ConfigManager.DEFAULT_CONFIG.copy() + + # Check if it's the very old single-job format + if "backup_type" in data: + job = ConfigManager.create_new_job() + job["name"] = "Default Backup" + job["type"] = data.get("backup_type", 0) + job["dest"] = data.get("dest_path", "") + job["source"] = data.get("file_source", "") + job["zip"] = data.get("zip_enabled", True) + + if job["type"] == 1: # MySQL + job["host"] = data.get("mysql_host", "localhost") + job["port"] = data.get("mysql_port", "3306") + job["user"] = data.get("mysql_user", "root") + job["password"] = data.get("mysql_pass", "") + job["database"] = data.get("mysql_db", "") + elif job["type"] == 2: # Postgres + job["host"] = data.get("pg_host", "localhost") + job["port"] = data.get("pg_port", "5432") + job["user"] = data.get("pg_user", "postgres") + job["password"] = data.get("pg_pass", "") + job["database"] = data.get("pg_db", "") + + config["jobs"] = [job] + config["schedule_enabled"] = data.get("schedule_enabled", False) + config["schedule_interval"] = data.get("schedule_interval", 60) + config["schedule_unit"] = data.get("schedule_unit", "Menit") + else: + # Already in new format + config.update(data) + + return config + + @staticmethod + def save_config(config): + try: + json_data = json.dumps(config, indent=4) + # Simple Base64 Encode + encoded_data = base64.b64encode(json_data.encode('utf-8')) + + with open(CONFIG_FILE, 'wb') as f: + f.write(encoded_data) + except Exception as e: + print("Error saving config: {}".format(e)) diff --git a/dist-winXP/ProBackup.exe b/dist-winXP/ProBackup.exe new file mode 100644 index 0000000..4c55bad Binary files /dev/null and b/dist-winXP/ProBackup.exe differ diff --git a/extract_tools.py b/extract_tools.py new file mode 100644 index 0000000..74d1488 --- /dev/null +++ b/extract_tools.py @@ -0,0 +1,118 @@ +import os +import zipfile +import shutil +import re + +# Source and Destination +SOURCE_DIR = 'sql_tools_zip' +DEST_DIR = 'sql_tools' + +# Mapping structure: (Zip Pattern, Target Folder Structure, Executables to Extract) +# Target Folder Structure: e.g. "mysql/{version}" where {version} is extracted from filename +RULES = [ + { + 'pattern': r'mysql-(\d+\.\d+)\.\d+-winx64\.zip', + 'type': 'mysql', + 'bin_path': 'bin/', # usually inside root folder of zip + 'executables': ['mysqldump.exe'] + }, + { + 'pattern': r'mariadb-(\d+\.\d+)\.\d+-winx64.*\.zip', + 'type': 'mariadb', + 'bin_path': 'bin/', + 'executables': ['mariadb-dump.exe', 'mysqldump.exe'] # Try both + }, + { + 'pattern': r'postgresql-(\d+\.\d+).*windows-x64-binaries\.zip', + 'type': 'postgres', + 'bin_path': 'pgsql/bin/', # pg binaries zip usually has pgsql/bin + 'executables': ['pg_dump.exe', 'pg_dumpall.exe', 'libpq.dll', 'libiconv-2.dll', 'libintl-8.dll', 'libssl-1_1-x64.dll', 'libcrypto-1_1-x64.dll'] # DLLs might be needed! + # Modern PG zip structure might differ, checking pgsql/bin is standard for binaries zip + }, + { + # Special handler for PG 18 if pattern differs or standard regex fails + 'pattern': r'postgresql-(\d+)\.\d+.*windows-x64-binaries\.zip', + 'type': 'postgres', + 'bin_path': 'pgsql/bin/', + 'executables': ['pg_dump.exe', 'pg_dumpall.exe'] + } +] + +def ensure_dir(path): + if not os.path.exists(path): + os.makedirs(path) + +def extract_tools(): + if not os.path.exists(SOURCE_DIR): + print("Source directory '{}' not found.".format(SOURCE_DIR)) + return + + ensure_dir(DEST_DIR) + + files = [f for f in os.listdir(SOURCE_DIR) if f.lower().endswith('.zip')] + + print("Found {} zip files.".format(len(files))) + + for filename in files: + # Match rule + matched_rule = None + version = None + + for rule in RULES: + match = re.search(rule['pattern'], filename) + if match: + matched_rule = rule + version = match.group(1) + break + + if not matched_rule: + print("Skipping unknown file: {}".format(filename)) + continue + + db_type = matched_rule['type'] + target_dir = os.path.join(DEST_DIR, db_type, version) + + print("Processing {} -> {}...".format(filename, target_dir)) + + try: + with zipfile.ZipFile(os.path.join(SOURCE_DIR, filename), 'r') as zf: + # Find the internal path prefix (usually top folder) + # We assume binaries are in {top_folder}/{bin_path} or just {bin_path} + # Let's search for the executable to determine path + + for exe in matched_rule['executables']: + # find file in zip + target_member = None + for name in zf.namelist(): + if name.endswith("/{}".format(exe)) or name == exe: + # Verify if it looks like a bin folder + if matched_rule['bin_path'].strip('/') in name: + target_member = name + break + + if target_member: + ensure_dir(target_dir) + source = zf.open(target_member) + target_file_path = os.path.join(target_dir, exe) + with open(target_file_path, "wb") as target: + shutil.copyfileobj(source, target) + print(" Extracted: {}".format(exe)) + + # For Postgres, we might need DLLs. + # Simple logic: if 'postgres' in db_type, try to grab standard DLLs in same dir as exe + if db_type == 'postgres': + parent = os.path.dirname(target_member) + for item in zf.namelist(): + if os.path.dirname(item) == parent and item.lower().endswith('.dll'): + dll_name = os.path.basename(item) + with open(os.path.join(target_dir, dll_name), "wb") as dll_out: + shutil.copyfileobj(zf.open(item), dll_out) + + else: + print(" Warning: {} not found in {}".format(exe, filename)) + + except Exception as e: + print(" Error extracting {}: {}".format(filename, e)) + +if __name__ == "__main__": + extract_tools() diff --git a/fix_qt_paths.bat b/fix_qt_paths.bat new file mode 100644 index 0000000..1f5c65d --- /dev/null +++ b/fix_qt_paths.bat @@ -0,0 +1,24 @@ +@echo off +echo ======================================================== +echo MEMPERBAIKI PATH PYQT5 (QT.CONF) +echo ======================================================== +echo. +echo Masalah: PyQt5 manual Anda mengira dia ada di C:/Qt/5.5.0 +echo Solusi: Kita buat file C:\Python34\qt.conf untuk mengarahkannya ke yang benar. +echo. + +set TARGET_CONF=C:\Python34\qt.conf + +echo [Paths] > "%TARGET_CONF%" +echo Prefix = C:/Python34/Lib/site-packages/PyQt5 >> "%TARGET_CONF%" +echo Plugins = plugins >> "%TARGET_CONF%" + +echo File terbuat: %TARGET_CONF% +echo Isi file: +type "%TARGET_CONF%" +echo. +echo ======================================================== +echo SELESAI! +echo ======================================================== +echo Silakan jalankan 'build_xp.bat' lagi. +pause diff --git a/icon.ico b/icon.ico new file mode 100644 index 0000000..523be34 Binary files /dev/null and b/icon.ico differ diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..7172959 Binary files /dev/null and b/icon.png differ diff --git a/install_deps_xp.bat b/install_deps_xp.bat new file mode 100644 index 0000000..c74bac3 --- /dev/null +++ b/install_deps_xp.bat @@ -0,0 +1,118 @@ +@echo off +setlocal + +echo ======================================================== +echo OTOMATISASI INSTALASI DEPENDENSI PROBACKUP (XP TARGET) +echo ======================================================== +echo. +echo Pastikan Python 3.4 terinstall di C:\Python34 +echo. +pause + +set PY_PATH=C:\Python34 +set SITE_PACKAGES=%PY_PATH%\Lib\site-packages +set PYTHON_EXE="%PY_PATH%\python.exe" + +:: ---------------------------------------------------------- +:: 1. CLEANUP +:: ---------------------------------------------------------- +echo. +echo [1/8] Membersihkan instalasi PIP lama... + +if exist "%SITE_PACKAGES%\pip" rd /s /q "%SITE_PACKAGES%\pip" +if exist "%SITE_PACKAGES%\setuptools" rd /s /q "%SITE_PACKAGES%\setuptools" +for /d %%G in ("%SITE_PACKAGES%\pip-*.dist-info") do rd /s /q "%%G" + +:: ---------------------------------------------------------- +:: 2. ENSURE PIP +:: ---------------------------------------------------------- +echo. +echo [2/8] Menginstall Pip Bawaan... +%PYTHON_EXE% -m ensurepip --default-pip +if %errorlevel% neq 0 goto :Error + +:: ---------------------------------------------------------- +:: 3. UPDATE PIP & BUILD TOOLS +:: ---------------------------------------------------------- +echo. +echo [3/8] Mencoba update Pip, Setuptools, Wheel... +%PYTHON_EXE% -m pip install pip==19.1.1 --no-cache-dir +:: Downgrade setuptools to 39.2.0 (Stable for Py3.4 era) +%PYTHON_EXE% -m pip install setuptools==39.2.0 --no-cache-dir +%PYTHON_EXE% -m pip install wheel --no-cache-dir + +:: ---------------------------------------------------------- +:: 4. INSTALL CYCLES (MANUAL PEFILE) +:: ---------------------------------------------------------- +echo. +echo [4/8] Menginstall Pefile dan Altgraph... +:: Gunakan versi jadul yang stabil untuk Py3.4 +%PYTHON_EXE% -m pip install altgraph==0.16.1 --no-cache-dir +%PYTHON_EXE% -m pip install pefile==2017.11.5 --no-cache-dir +%PYTHON_EXE% -m pip install future --no-cache-dir + +:: ---------------------------------------------------------- +:: 5. INSTALL MAIN LIBS +:: ---------------------------------------------------------- +echo. +echo [5/8] Menginstall Library Lain... +%PYTHON_EXE% -m pip install pymysql==0.9.3 --no-cache-dir +%PYTHON_EXE% -m pip install psycopg2==2.7.7 --no-cache-dir +%PYTHON_EXE% -m pip install apscheduler==3.6.3 --no-cache-dir + +:: ---------------------------------------------------------- +:: 6. INSTALL PYINSTALLER +:: ---------------------------------------------------------- +echo. +echo [6/8] Menginstall PyInstaller... +:: PyInstaller 3.4 is the robust choice for Py 3.4 +%PYTHON_EXE% -m pip install pyinstaller==3.4 --no-cache-dir + +:: ---------------------------------------------------------- +:: 7. PYQT5 CHECK +:: ---------------------------------------------------------- +echo. +echo [7/8] Cek Instalasi PyQt5... +%PYTHON_EXE% -c "import PyQt5" 2>nul +if %errorlevel% equ 0 goto :PyQtFound + +echo - Mencoba install PyQt5 via pip (backup plan)... +%PYTHON_EXE% -m pip install PyQt5==5.6 --no-cache-dir +if %errorlevel% equ 0 goto :PyQtFound + +goto :PyQtMissing + +:PyQtFound +echo - PyQt5 terdeteksi. Oke. +goto :Finalize + +:PyQtMissing +echo. +echo !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +echo !!! ERROR: PYQT5 TIDAK DITEMUKAN / GAGAL INSTALL !! +echo !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +echo. +echo Anda harus menginstall MANUAL file ini: +echo 1. PyQt5-5.5.1-gpl-Py3.4-Qt5.5.1-x32.exe +echo 2. Download dari SourceForge. +echo. +echo Setelah install manual, jalankan script ini lagi dipersilakan (opsional). +echo Atau langsung coba build_xp.bat jika yakin sudah install. +echo. +pause +exit /b + +:Error +echo. +echo !!! TERJADI ERROR FATAL !!! +echo Cek pesan error di atas. +pause +exit /b + +:Finalize +echo. +echo ======================================================== +echo SELESAI! SIAP BUILD! +echo ======================================================== +echo Sekarang jalankan 'build_xp.bat'. +pause diff --git a/keygen.py b/keygen.py new file mode 100644 index 0000000..909b956 --- /dev/null +++ b/keygen.py @@ -0,0 +1,104 @@ +import sys +import tkinter as tk +from tkinter import messagebox +from license_manager import LicenseManager + +class KeygenApp: + def __init__(self, root): + self.root = root + self.root.title("ProBackup Key Generator (XP)") + self.root.geometry("450x250") + self.root.resizable(False, False) + + # Center window + self.center_window() + + # Styles + bg_color = "#f0f0f0" + self.root.configure(bg=bg_color) + + # Title + tk.Label(root, text="ProBackup Activation Generator", font=("Arial", 14, "bold"), bg=bg_color).pack(pady=10) + + # HWID Input + input_frame = tk.Frame(root, bg=bg_color) + input_frame.pack(pady=5, padx=20, fill="x") + + tk.Label(input_frame, text="Machine ID (Paste here):", bg=bg_color).pack(anchor="w") + self.hwid_entry = tk.Entry(input_frame, font=("Consolas", 10)) + self.hwid_entry.pack(fill="x", pady=2) + # Add right click menu for paste (optional but helpful on older systems) + self.create_context_menu(self.hwid_entry) + + # Generate Button + self.btn_generate = tk.Button(root, text="GENERATE KEY", command=self.generate_key, + bg="#4CAF50", fg="white", font=("Arial", 10, "bold"), height=1) + self.btn_generate.pack(pady=10) + + # Output + output_frame = tk.Frame(root, bg=bg_color) + output_frame.pack(pady=5, padx=20, fill="x") + + tk.Label(output_frame, text="Activation Key:", bg=bg_color).pack(anchor="w") + self.key_entry = tk.Entry(output_frame, font=("Consolas", 10), state="readonly", fg="blue") + self.key_entry.pack(fill="x", pady=2) + self.create_context_menu(self.key_entry) + + # Instructions + tk.Label(root, text="Copy the Activation Key and send it to the user.", + bg=bg_color, fg="#666666", font=("Arial", 8)).pack(side="bottom", pady=10) + + def center_window(self): + self.root.update_idletasks() + width = self.root.winfo_width() + height = self.root.winfo_height() + x = (self.root.winfo_screenwidth() // 2) - (width // 2) + y = (self.root.winfo_screenheight() // 2) - (height // 2) + self.root.geometry('{}x{}+{}+{}'.format(width, height, x, y)) + + def create_context_menu(self, widget): + menu = tk.Menu(self.root, tearoff=0) + menu.add_command(label="Cut", command=lambda: widget.event_generate("<>")) + menu.add_command(label="Copy", command=lambda: widget.event_generate("<>")) + menu.add_command(label="Paste", command=lambda: widget.event_generate("<>")) + + def show_menu(event): + menu.post(event.x_root, event.y_root) + + widget.bind("", show_menu) + + def generate_key(self): + hwid = self.hwid_entry.get().strip() + if not hwid: + messagebox.showerror("Error", "Please enter a Machine ID first.") + return + + try: + full_sig = LicenseManager.generate_signature(hwid) + activation_key = full_sig[:32] + + self.key_entry.config(state="normal") + self.key_entry.delete(0, tk.END) + self.key_entry.insert(0, activation_key) + self.key_entry.config(state="readonly") + + # Auto copy to clipboard + self.root.clipboard_clear() + self.root.clipboard_append(activation_key) + messagebox.showinfo("Success", "Key generated and copied to clipboard!") + + except Exception as e: + messagebox.showerror("Error", "Generasi gagal: {}".format(e)) + +def main(): + root = tk.Tk() + # Try setting icon if available + try: + root.iconbitmap("icon.ico") + except: pass + + app = KeygenApp(root) + root.mainloop() + +if __name__ == "__main__": + main() diff --git a/license_manager.py b/license_manager.py new file mode 100644 index 0000000..e2ad913 --- /dev/null +++ b/license_manager.py @@ -0,0 +1,144 @@ +import hashlib +import uuid +import subprocess +import platform +import os +import base64 + +# Simple Secret Salt - In a real app, obfuscate this or use asymmetric keys (RSA) +# For this level, a symmetric secret is "good enough" for basic protection. +SECRET_SALT = "ProBackup_Secret_Salt_2026_!@#" + +# Flag to hide console window on Windows +CREATE_NO_WINDOW = 0x08000000 + +class LicenseManager: + @staticmethod + def get_motherboard_id(): + """Gets the motherboard UUID using WMIC (Windows only).""" + try: + result = subprocess.run( + ['wmic', 'csproduct', 'get', 'uuid'], + capture_output=True, text=True, timeout=5, + creationflags=CREATE_NO_WINDOW + ) + lines = result.stdout.strip().split('\n') + for line in lines: + line = line.strip() + if line and line != 'UUID': + return line + except: + pass + return None + + @staticmethod + def get_processor_id(): + """Gets the processor ID using WMIC (Windows only).""" + try: + result = subprocess.run( + ['wmic', 'cpu', 'get', 'processorid'], + capture_output=True, text=True, timeout=5, + creationflags=CREATE_NO_WINDOW + ) + lines = result.stdout.strip().split('\n') + for line in lines: + line = line.strip() + if line and line != 'ProcessorId': + return line + except: + pass + # Fallback to platform.processor() + return platform.processor() + + @staticmethod + def _hash_to_hwid(raw_id): + """Converts raw ID string to formatted HWID.""" + hwid_hash = hashlib.sha256(raw_id.encode()).hexdigest().upper() + return "{}-{}-{}-{}".format(hwid_hash[:4], hwid_hash[4:8], hwid_hash[8:12], hwid_hash[12:16]) + + @staticmethod + def get_hardware_id(): + """ + Returns hardware ID based on combination of Motherboard UUID + Processor ID. + This is for DISPLAY purposes to customer. + """ + mb_id = LicenseManager.get_motherboard_id() or "UNKNOWN_MB" + proc_id = LicenseManager.get_processor_id() or "UNKNOWN_CPU" + + # Combine both IDs for display + combined = "COMBO-{}-{}".format(mb_id, proc_id) + return LicenseManager._hash_to_hwid(combined) + + @staticmethod + def get_mb_hwid(): + """Returns HWID based on motherboard only.""" + mb_id = LicenseManager.get_motherboard_id() or "UNKNOWN_MB" + return LicenseManager._hash_to_hwid("MB-{}".format(mb_id)) + + @staticmethod + def get_cpu_hwid(): + """Returns HWID based on processor only.""" + proc_id = LicenseManager.get_processor_id() or "UNKNOWN_CPU" + return LicenseManager._hash_to_hwid("CPU-{}".format(proc_id)) + + @staticmethod + def generate_signature(hwid): + """Generates the expected signature for a given HWID.""" + data = "{}|{}".format(hwid, SECRET_SALT) + return hashlib.sha256(data.encode()).hexdigest().upper() + + @staticmethod + def validate_license(key, hwid=None): + """ + Validates license with flexible hardware check. + Returns tuple: (is_valid, status) + - status: 'valid', 'mb_changed', 'cpu_changed', 'both_changed', 'invalid' + """ + if not key: + return False, 'invalid' + + key = key.strip().upper() + + # Check against combined HWID (exact match - preferred) + combined_hwid = LicenseManager.get_hardware_id() + expected_combined = LicenseManager.generate_signature(combined_hwid)[:32] + if key == expected_combined: + return True, 'valid' + + # Check against motherboard only + mb_hwid = LicenseManager.get_mb_hwid() + expected_mb = LicenseManager.generate_signature(mb_hwid)[:32] + mb_match = (key == expected_mb) + + # Check against processor only + cpu_hwid = LicenseManager.get_cpu_hwid() + expected_cpu = LicenseManager.generate_signature(cpu_hwid)[:32] + cpu_match = (key == expected_cpu) + + # If either matches, license is valid (single hardware change allowed) + if mb_match: + return True, 'cpu_changed' # MB matches, CPU must have changed + if cpu_match: + return True, 'mb_changed' # CPU matches, MB must have changed + + # Neither matches - could be both changed or completely invalid key + return False, 'both_changed' + + @staticmethod + def save_license(key): + try: + with open("license.dat", "w") as f: + f.write(key.strip()) + return True + except: + return False + + @staticmethod + def load_license(): + if not os.path.exists("license.dat"): + return None + try: + with open("license.dat", "r") as f: + return f.read().strip() + except: + return None diff --git a/main.py b/main.py new file mode 100644 index 0000000..023edcb --- /dev/null +++ b/main.py @@ -0,0 +1,1532 @@ +import sys +import os +import ctypes +import time +from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, + QHBoxLayout, QLabel, QLineEdit, QPushButton, + QComboBox, QStackedWidget, QCheckBox, QTextEdit, + QProgressBar, QFileDialog, QMessageBox, QGroupBox, + QSystemTrayIcon, QMenu, QSpinBox, QTabWidget, + QListWidget, QSplitter, QListWidgetItem, QInputDialog, QDialog, + QSizePolicy, QAction, QStyle) +from PyQt5.QtCore import Qt, QThread, pyqtSignal as Signal, QTimer, QTime +from PyQt5.QtGui import QIcon + +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from apscheduler.triggers.interval import IntervalTrigger +from backup_manager import FileBackup, MySQLBackup, PostgresBackup +from config_manager import ConfigManager +from license_manager import LicenseManager +from registry_manager import RegistryManager +from registration_dialog import RegistrationDialog + +# --- Modern Dark Theme Stylesheet --- +STYLESHEET = """ +QMainWindow { + background-color: #282a36; +} +QWidget { + color: #f8f8f2; + font-family: "Segoe UI", sans-serif; + font-size: 14px; +} +QMessageBox { + background-color: #f8f8f2; +} +QMessageBox QLabel { + color: #282a36; + font-size: 13px; +} +QMessageBox QPushButton { + background-color: #6272a4; + color: #f8f8f2; + min-width: 80px; + padding: 6px 12px; +} +QGroupBox { + border: 1px solid #6272a4; + border-radius: 5px; + margin-top: 20px; + font-weight: bold; +} +QGroupBox::title { + subcontrol-origin: margin; + left: 10px; + padding: 0 5px; + color: #bd93f9; +} +QLineEdit, QComboBox, QSpinBox, QTimeEdit { + background-color: #44475a; + border: 1px solid #6272a4; + border-radius: 4px; + padding: 5px; + color: #f8f8f2; +} +QLineEdit:focus, QComboBox:focus, QSpinBox:focus { + border: 1px solid #bd93f9; +} +QComboBox QAbstractItemView { + background-color: #44475a; + color: #f8f8f2; + selection-background-color: #6272a4; + selection-color: #f8f8f2; + border: 1px solid #6272a4; +} +QListWidget { + background-color: #44475a; + border: 1px solid #6272a4; + border-radius: 4px; + padding: 5px; + outline: none; +} +QListWidget::item { + padding: 10px; + border-bottom: 1px solid #6272a4; +} +QListWidget::item:selected { + background-color: #bd93f9; + color: #282a36; + font-weight: bold; +} +QPushButton { + background-color: #6272a4; + color: #f8f8f2; + border: none; + border-radius: 4px; + padding: 8px 16px; + font-weight: bold; +} +QPushButton:hover { + background-color: #bd93f9; +} +QPushButton:pressed { + background-color: #ff79c6; +} +QPushButton#Secondary { + background-color: #44475a; +} +QPushButton#Secondary:hover { + background-color: #6272a4; +} +QProgressBar { + border: 1px solid #44475a; + border-radius: 4px; + text-align: center; + background-color: #282a36; +} +QProgressBar::chunk { + background-color: #50fa7b; + width: 10px; +} +QTextEdit { + background-color: #282a36; + border: 1px solid #44475a; + border-radius: 4px; + color: #8be9fd; + font-family: Consolas, monospace; +} +QLabel { + color: #f8f8f2; +} +QTabWidget::pane { + border: 1px solid #44475a; +} +QTabBar::tab { + background: #44475a; + color: #f8f8f2; + padding: 10px 25px; + margin-left: 2px; + min-width: 140px; +} +QTabBar::tab:selected { + background: #6272a4; + font-weight: bold; +} +QSpinBox, QTimeEdit { + padding: 5px; + border: 1px solid #6272a4; + border-radius: 4px; + min-height: 25px; /* Compact */ +} +QSpinBox::up-button, QSpinBox::down-button, +QTimeEdit::up-button, QTimeEdit::down-button { + /* Hide internal buttons if we use external ones, + but for now just reset to default or hide them via python setButtonSymbols */ + width: 0px; + border: none; + background: transparent; +} +""" + +class WorkerThread(QThread): + progress_signal = Signal(int) + log_signal = Signal(str) + finished_signal = Signal() + error_signal = Signal(str) + success_signal = Signal() # New signal for success counting + + def __init__(self, job_configs): + super().__init__() + self.job_configs = job_configs # List of jobs + self._is_cancelled = False + + def run(self): + total_jobs = len(self.job_configs) + for i, job_conf in enumerate(self.job_configs): + if self._is_cancelled: + self.log_signal.emit(">>> Series Cancelled.") + break + + job_name = job_conf.get('name', 'Unknown Job') + self.log_signal.emit("--- Starting Job [{}/{}]: {} ---".format(i+1, total_jobs, job_name)) + + # Map Config to Strategy + strategy = None + jtype = job_conf.get('type') + + # Prepare config dict for strategy + # Flatten it a bit for the strategy which expects keys at top level + run_config = job_conf.copy() + + if jtype == 0: # File + strategy = FileBackup() + run_config['source'] = job_conf.get('source') + run_config['archive'] = job_conf.get('zip') + run_config['retention_days'] = job_conf.get('retention_days', 0) + elif jtype == 1: # MySQL + strategy = MySQLBackup() + run_config['retention_days'] = job_conf.get('retention_days', 0) + run_config['compress'] = job_conf.get('compress', False) + elif jtype == 2: # Postgres + strategy = PostgresBackup() + run_config['retention_days'] = job_conf.get('retention_days', 0) + run_config['compress'] = job_conf.get('compress', False) + + if strategy: + strategy.update_signal = self.progress_signal + strategy.log_signal = self.log_signal + try: + strategy.run_backup(run_config) + self.success_signal.emit() # Success! + except Exception as e: + self.log_signal.emit("!!! Error in job {}: {}".format(job_name, str(e))) + + time.sleep(1) # Breath + + self.finished_signal.emit() + + def cancel(self): + self._is_cancelled = True + +class BackupApp(QMainWindow): + start_backup_signal = Signal(str) # Now carries job_id + + def __init__(self, trial_mode=False, instance_socket=None): + super().__init__() + self.setWindowTitle("ProBackup - Utilitas Backup Multi-Pekerjaan") + self.resize(900, 550) # Extremely Compact for 1366x768 screens + self.setStyleSheet(STYLESHEET) + + self.trial_mode = trial_mode + self.instance_socket = instance_socket + self.trial_timer = None + + # Set window icon + icon_path = os.path.join(os.path.dirname(__file__), "icon.png") + if os.path.exists(icon_path): + self.setWindowIcon(QIcon(icon_path)) + + self.worker = None + self.scheduler = BackgroundScheduler() + self.scheduler.start() + self.start_backup_signal.connect(self.scheduled_backup_wrapper) + + # State + self.jobs = [] # List of job dicts + self.current_job_id = None + + # Trial Data + self.trial_usage_count = RegistryManager.get_trial_count() + + self.setup_ui() + self.setup_tray() + self.setup_single_instance_listener() + + # Ensure sql_tools folder exists + self.ensure_sql_tools_folder() + + self.load_settings() + + if self.trial_mode: + # Check if strict limit reached immediately + if self.trial_usage_count >= 10: + QMessageBox.warning(None, "Masa Trial Habis", + "Kesempatan pemakaian trial (10x backup) telah habis.\n" + "Silakan aktivasi aplikasi.\n\n" + "Hubungi WA: +62 817-0380-6655") + if not self.open_activation_return_success(): + sys.exit(0) + else: + self.start_trial_timer() + + def ensure_sql_tools_folder(self): + """Create sql_tools folder if it doesn't exist.""" + # Determine working directory + if getattr(sys, 'frozen', False): + base_dir = os.path.dirname(sys.executable) + else: + base_dir = os.path.dirname(os.path.abspath(__file__)) + + sql_tools_dir = os.path.join(base_dir, 'sql_tools') + if not os.path.exists(sql_tools_dir): + try: + os.makedirs(sql_tools_dir) + # Ensure readme exists + readme_path = os.path.join(sql_tools_dir, 'README.txt') + with open(readme_path, 'w') as f: + f.write("Letakkan file mysqldump.exe, pg_dump.exe, dan pg_dumpall.exe di sini.\n" + "Aplikasi akan memprioritaskan tool yang ada di folder ini.") + except Exception as e: + print("Gagal membuat folder sql_tools: {}".format(e)) + + def setup_ui(self): + central_widget = QWidget() + self.setCentralWidget(central_widget) + main_layout = QVBoxLayout(central_widget) + main_layout.setSpacing(5) # Tighter spacing + main_layout.setContentsMargins(5, 5, 5, 5) # Smaller margins + + # Tabs + self.tabs = QTabWidget() + + # Header as Corner Widget to save vertical space + header = QLabel("Utilitas Backup") + header.setStyleSheet("font-size: 18px; font-weight: bold; color: #ff79c6; padding-right: 10px;") + self.tabs.setCornerWidget(header, Qt.TopRightCorner) + + main_layout.addWidget(self.tabs) + + # --- Tab 1: Jobs Management --- + jobs_tab = QWidget() + jobs_layout = QHBoxLayout(jobs_tab) + + # Splitter: List vs Details + splitter = QSplitter(Qt.Horizontal) + + # Left: List + left_widget = QWidget() + left_layout = QVBoxLayout(left_widget) + left_layout.setContentsMargins(0,0,0,0) + + self.job_list = QListWidget() + self.job_list.itemClicked.connect(self.on_job_selected) + + list_btn_layout = QHBoxLayout() + add_btn = QPushButton("Baru") + add_btn.clicked.connect(self.new_job) + + copy_btn = QPushButton("Salin") + copy_btn.setToolTip("Duplikat pekerjaan yang dipilih") + copy_btn.clicked.connect(self.duplicate_job) + + del_btn = QPushButton("Hapus") + del_btn.setObjectName("Secondary") + del_btn.clicked.connect(self.delete_job) + + list_btn_layout.addWidget(add_btn) + list_btn_layout.addWidget(copy_btn) + list_btn_layout.addWidget(del_btn) + + left_layout.addWidget(QLabel("Daftar Pekerjaan:")) + left_layout.addWidget(self.job_list) + left_layout.addLayout(list_btn_layout) + + # Right: Form + self.job_form_widget = QGroupBox("Detail Pekerjaan") + self.job_form_widget.setEnabled(False) + form_layout = QVBoxLayout(self.job_form_widget) + + # Combined Name & Type Row + job_info_row = QHBoxLayout() + job_info_row.setContentsMargins(0, 0, 0, 0) + + # Name + job_info_row.addWidget(QLabel("Pekerjaan:")) + self.name_input = QLineEdit() + self.name_input.setPlaceholderText("New Backup Job") + self.name_input.textChanged.connect(self.auto_save_current_ui) + job_info_row.addWidget(self.name_input) + + # Type + job_info_row.addSpacing(10) + job_info_row.addWidget(QLabel("Tipe:")) + self.type_combo = QComboBox() + self.type_combo.addItems(["File/Folder", "Database MySQL", "Database PostgreSQL"]) + self.type_combo.setFixedWidth(150) # Compact width + self.type_combo.currentIndexChanged.connect(self.on_type_changed) + job_info_row.addWidget(self.type_combo) + + form_layout.addLayout(job_info_row) + + # Stack + # Content Split Layout + content_split = QHBoxLayout() + + # --- LEFT COLUMN (Source) --- + source_col = QVBoxLayout() + source_col.setContentsMargins(0, 0, 5, 0) + + # Stack (Contains Source Pages: File / DB) + self.stack = QStackedWidget() + # Ensure stack pages (which have groups) fill width + self.stack.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) + + self.page_file = self.create_file_page() + self.stack.addWidget(self.page_file) + self.page_mysql = self.create_db_page("MySQL") + self.stack.addWidget(self.page_mysql) + self.page_postgres = self.create_db_page("PostgreSQL") + self.stack.addWidget(self.page_postgres) + + source_col.addWidget(self.stack) + source_col.addStretch() # Push source content up + + content_split.addLayout(source_col, stretch=1) + + # --- RIGHT COLUMN (Target & Schedule) --- + target_col = QVBoxLayout() + target_col.setContentsMargins(5, 0, 0, 0) + + # 1. Destination + dest_group = QGroupBox("Tujuan Backup") + dest_layout = QHBoxLayout() + dest_layout.setContentsMargins(5, 5, 5, 5) + self.dest_input = QLineEdit() + self.dest_input.setPlaceholderText("Lokasi tujuan backup...") + self.dest_input.textChanged.connect(self.auto_save_current_ui) + browse_dest_btn = QPushButton("Jelajahi") + browse_dest_btn.setObjectName("Secondary") + browse_dest_btn.clicked.connect(self.browse_dest) + + open_dest_btn = QPushButton("Buka") + open_dest_btn.setObjectName("Secondary") + open_dest_btn.setToolTip("Buka folder tujuan backup di File Explorer") + open_dest_btn.clicked.connect(self.open_backup_folder) + + dest_layout.addWidget(self.dest_input) + dest_layout.addWidget(browse_dest_btn) + dest_layout.addWidget(open_dest_btn) + + # 1b. Global Compression (Next to Browse) + self.global_compress_check = QCheckBox("Kompres") + self.global_compress_check.setToolTip("Jika aktif, hasil backup akan dikombinasikan menjadi file .zip") + self.global_compress_check.clicked.connect(self.auto_save_current_ui) + dest_layout.addWidget(self.global_compress_check) + + dest_group.setLayout(dest_layout) + target_col.addWidget(dest_group) + + # 2. Schedule + sched_group = QGroupBox("Pengaturan Jadwal") + sched_layout = QHBoxLayout() + sched_layout.setContentsMargins(5, 5, 5, 5) + + self.job_sched_enable = QCheckBox("Aktifkan Jadwal") + self.job_sched_enable.toggled.connect(self.toggle_job_schedule) + sched_layout.addWidget(self.job_sched_enable) + + self.job_sched_type = QComboBox() + self.job_sched_type.addItems(["Interval", "Harian"]) + self.job_sched_type.setFixedWidth(100) + self.job_sched_type.currentIndexChanged.connect(self.on_sched_type_changed) + sched_layout.addWidget(self.job_sched_type) + + self.sched_stack = QStackedWidget() + self.sched_stack.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) + + page_interval = QWidget() + pi_layout = QHBoxLayout(page_interval) + pi_layout.setContentsMargins(0,0,0,0) + pi_layout.addWidget(QLabel("Setiap:")) + + self.job_interval_spin = QSpinBox() + self.job_interval_spin.setRange(1, 9999) + self.job_interval_spin.setValue(60) + self.job_interval_unit = QComboBox() + self.job_interval_unit.addItems(["Menit", "Jam"]) + self.create_spinbox_with_buttons(self.job_interval_spin, pi_layout) + pi_layout.addWidget(self.job_interval_unit) + pi_layout.addStretch() + self.sched_stack.addWidget(page_interval) + + page_daily = QWidget() + pd_layout = QHBoxLayout(page_daily) + pd_layout.setContentsMargins(0,0,0,0) + pd_layout.addWidget(QLabel("Pukul:")) + from PyQt5.QtWidgets import QTimeEdit + self.job_time_edit = QTimeEdit() + self.job_time_edit.setDisplayFormat("HH:mm") + self.create_spinbox_with_buttons(self.job_time_edit, pd_layout) + pd_layout.addStretch() + self.sched_stack.addWidget(page_daily) + + sched_layout.addWidget(self.sched_stack) + + sched_layout.addStretch() + sched_layout.addWidget(QLabel("| Retensi:")) + self.retention_spin = QSpinBox() + self.retention_spin.setRange(0, 3650) + self.retention_spin.setSpecialValueText("Selamanya") + self.retention_spin.setToolTip("Hapus backup lebih lama dari X hari. 0 = Simpan Selamanya.") + self.create_spinbox_with_buttons(self.retention_spin, sched_layout) + + sched_group.setLayout(sched_layout) + target_col.addWidget(sched_group) + + # 3. Global Compression (REMOVED from here) + + # 4. Action Buttons (Save & Run in one row) + action_row = QHBoxLayout() + + save_job_btn = QPushButton("Simpan") + save_job_btn.clicked.connect(self.save_current_job_to_list) + + self.run_single_btn = QPushButton("Jalankan") + self.run_single_btn.setStyleSheet("background-color: #50fa7b; color: #282a36; font-weight: bold;") + self.run_single_btn.clicked.connect(self.run_current_job) + + action_row.addWidget(save_job_btn) + action_row.addWidget(self.run_single_btn) + + target_col.addLayout(action_row) + target_col.addStretch() + + content_split.addLayout(target_col, stretch=1) + + form_layout.addLayout(content_split) + + # REMOVED separate run_row + + form_layout.addStretch() + form_layout.addStretch() + + splitter.addWidget(left_widget) + splitter.addWidget(self.job_form_widget) + splitter.setStretchFactor(1, 2) + + jobs_layout.addWidget(splitter) + self.tabs.addTab(jobs_tab, "Pekerjaan Backup") + + # --- Tab 2: Settings (General) --- + # settings_tab = QWidget() + # ... skipped global schedule replacement ... + + # --- Bottom Area --- + + # --- Bottom Area --- + + bottom_layout = QHBoxLayout() + bottom_layout.setContentsMargins(0, 5, 0, 5) + + # 1. Progress Bar (Left, Expanding - Takes available space) + self.progress_bar = QProgressBar() + self.progress_bar.setValue(0) + self.progress_bar.setFormat("%p%") + self.progress_bar.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.progress_bar.setStyleSheet("text-align: center; height: 25px;") + bottom_layout.addWidget(self.progress_bar, stretch=1) + + # 2. Buttons (Right, Fit to Text) + + # Run All Button + self.run_all_btn = QPushButton("Jalankan Semua") + self.run_all_btn.setMinimumHeight(40) + # SizePolicy Preferred means it requests space based on content (text) + self.run_all_btn.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + self.run_all_btn.setMinimumWidth(120) # Min width for aesthetics + self.run_all_btn.clicked.connect(self.run_all_jobs) + bottom_layout.addWidget(self.run_all_btn) # No stretch = does not expand unnecessarily + + # Cancel Button + self.cancel_btn = QPushButton("Batal") + self.cancel_btn.setMinimumHeight(40) + self.cancel_btn.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + self.cancel_btn.setMinimumWidth(80) # Min width + self.cancel_btn.setObjectName("Secondary") + self.cancel_btn.setEnabled(False) + self.cancel_btn.clicked.connect(self.cancel_backup) + bottom_layout.addWidget(self.cancel_btn) + + main_layout.addLayout(bottom_layout) + + self.log_area = QTextEdit() + self.log_area.setReadOnly(True) + self.log_area.setMaximumHeight(150) + main_layout.addWidget(self.log_area) + + # Copyright & License Info + hwid = LicenseManager.get_hardware_id() + if self.trial_mode: + remaining = 10 - self.trial_usage_count + remaining = max(0, remaining) + status_text = "MODE COBA (Sisa: {})".format(remaining) + else: + status_text = "Teraktivasi" + + footer_text = "© Copyright by Wartana 2026 | ID Mesin: {} | Status: {}".format(hwid, status_text) + self.copyright_label = QLabel(footer_text) + self.copyright_label.setAlignment(Qt.AlignCenter) + self.copyright_label.setStyleSheet("color: #6272a4; font-size: 11px;") + main_layout.addWidget(self.copyright_label) + + def start_trial_timer(self): + # 30 Minutes Trial + self.log(">>> APLIKASI DALAM MODE TRIAL. Anda memiliki 10 kali pemakaian.") + self.trial_timer = QTimer(self) + self.trial_timer.setSingleShot(True) + self.trial_timer.timeout.connect(self.trial_expired) + self.trial_timer.start(1800000) # 30 mins in ms + + def trial_expired(self): + QMessageBox.warning(self, "Waktu Trial Habis", + "Masa percobaan telah berakhir.\n" + "Silakan aktivasi aplikasi.\n\n" + "Hubungi WA: +62 817-0380-6655") + if not self.open_activation_return_success(): + sys.exit(0) + + def open_activation_dialog(self): + # Legacy Wrapper + if not self.open_activation_return_success(): + sys.exit(0) + + def open_activation_return_success(self): + reg_dialog = RegistrationDialog() + if reg_dialog.exec() == QDialog.Accepted: + # Success + self.trial_mode = False + self.update_footer_status() + self.log(">>> Aplikasi berhasil diaktivasi!") + return True + return False + + def force_activation_dialog(self): + """Forces the activation dialog to appear (from tray menu).""" + reg_dialog = RegistrationDialog() + if reg_dialog.exec() == QDialog.Accepted: + self.trial_mode = False + self.update_footer_status() + self.update_tray_status() + self.log(">>> Aplikasi berhasil diaktivasi!") + QMessageBox.information(self, "Sukses", "Aktivasi berhasil!") + + def update_tray_status(self): + """Updates tray menu status after activation.""" + if hasattr(self, 'status_action'): + if self.trial_mode: + self.status_action.setText("Status: Mode Trial") + else: + self.status_action.setText("Status: Teraktivasi ✓") + if hasattr(self, 'activate_action'): + self.activate_action.setEnabled(self.trial_mode) + + def update_footer_status(self): + hwid = LicenseManager.get_hardware_id() + if self.trial_mode: + remaining = 10 - self.trial_usage_count + remaining = max(0, remaining) + status_text = "MODE COBA (Sisa: {})".format(remaining) + else: + status_text = "Teraktivasi" + + footer_text = "© Copyright by Wartana 2026 | ID Mesin: {} | Status: {}".format(hwid, status_text) + self.copyright_label.setText(footer_text) + + def check_trial_limit_ok(self): + if not self.trial_mode: + return True + + if self.trial_usage_count >= 10: + QMessageBox.warning(self, "Batas Trial Tercapai", + "Anda telah mencapai batas 10 kali pemakaian backup untuk versi trial.\n" + "Silakan aktivasi aplikasi untuk melanjutkan.\n\n" + "Hubungi WA: +62 817-0380-6655") + + if self.open_activation_return_success(): + return True + return False # Cancelled, but maybe don't exit app inside the check? + # The previous logic exited app. Let's keep it consistent or allow viewing? + # User said "di tutup, aplikasi dijalankan lagi mau". implies blocking is desired. + # But here we are mid-operation. + pass # Return False blocks the operation. + + return True + + def increment_trial_usage(self): + if self.trial_mode: + self.trial_usage_count = RegistryManager.increment_trial_count() + self.update_footer_status() + + def create_spinbox_with_buttons(self, widget, layout): + widget.setButtonSymbols(QSpinBox.NoButtons) + widget.setMinimumHeight(25) # Compact height + widget.setStyleSheet("font-size: 13px; font-weight: bold; padding: 0 5px;") + + minus_btn = QPushButton("-") + minus_btn.setFixedSize(25, 25) # Compact button + minus_btn.setToolTip("Kurangi") + # Fixed visibility: Smaller font, centered + minus_btn.setStyleSheet(""" + QPushButton { + background-color: #e74c3c; + color: #ffffff; + font-family: Arial, sans-serif; + font-size: 18px; + font-weight: 900; + border-radius: 4px; + border: none; + padding: 0px; + margin: 0px; + line-height: normal; + } + QPushButton:hover { background-color: #c0392b; } + QPushButton:pressed { background-color: #a93226; } + """) + minus_btn.clicked.connect(widget.stepDown) + + plus_btn = QPushButton("+") + plus_btn.setFixedSize(25, 25) # Compact button + plus_btn.setToolTip("Tambah") + # Fixed visibility + plus_btn.setStyleSheet(""" + QPushButton { + background-color: #2ecc71; + color: #ffffff; + font-family: Arial, sans-serif; + font-size: 18px; + font-weight: 900; + border-radius: 4px; + border: none; + padding: 0px; + margin: 0px; + line-height: normal; + } + QPushButton:hover { background-color: #27ae60; } + QPushButton:pressed { background-color: #219150; } + """) + plus_btn.clicked.connect(widget.stepUp) + + # Container to hold them tight + container = QWidget() + c_layout = QHBoxLayout(container) + c_layout.setContentsMargins(0,0,0,0) + c_layout.setSpacing(2) + + c_layout.addWidget(minus_btn) + c_layout.addWidget(widget) + c_layout.addWidget(plus_btn) + + layout.addWidget(container) + + # --- Job Management --- + def refresh_job_list(self): + self.job_list.clear() + for job in self.jobs: + item = QListWidgetItem(job['name']) + item.setData(Qt.UserRole, job['id']) + self.job_list.addItem(item) + + def new_job(self): + job = ConfigManager.create_new_job() + self.jobs.append(job) + self.refresh_job_list() + + # Select the new item + count = self.job_list.count() + self.job_list.setCurrentRow(count - 1) + self.on_job_selected(self.job_list.item(count - 1)) + self.name_input.setFocus() + self.save_global_settings() # Save immediately + + def duplicate_job(self): + item = self.job_list.currentItem() + if not item: return + + job_id = item.data(Qt.UserRole) + job_to_copy = next((j for j in self.jobs if j['id'] == job_id), None) + if not job_to_copy: return + + # Deep copy the job dict + import copy + new_job = copy.deepcopy(job_to_copy) + + # Assign new ID and update name + import uuid + new_job['id'] = str(uuid.uuid4()) + new_job['name'] = "{} (Copy)".format(new_job['name']) + + self.jobs.append(new_job) + self.refresh_job_list() + + # Select the new item + count = self.job_list.count() + self.job_list.setCurrentRow(count - 1) + self.on_job_selected(self.job_list.item(count - 1)) + self.name_input.setFocus() + self.save_global_settings() + self.log(">>> Pekerjaan diduplikasi: {}".format(new_job['name'])) + + def delete_job(self): + item = self.job_list.currentItem() + if not item: return + job_id = item.data(Qt.UserRole) + + confirm = QMessageBox.question(self, "Delete Job", "Are you sure you want to delete this job?", + QMessageBox.Yes | QMessageBox.No) + if confirm == QMessageBox.Yes: + self.jobs = [j for j in self.jobs if j['id'] != job_id] + self.refresh_job_list() + self.job_form_widget.setEnabled(False) + self.name_input.clear() + self.current_job_id = None + self.save_global_settings() + + def on_job_selected(self, item): + if not item: return + job_id = item.data(Qt.UserRole) + self.current_job_id = job_id + + # Find job data + job = next((j for j in self.jobs if j['id'] == job_id), None) + if not job: return + + self.job_form_widget.setEnabled(True) + + # Populate Form + self.name_input.setText(job['name']) + self.type_combo.setCurrentIndex(job['type']) + self.dest_input.setText(job.get('dest', '')) + + # File Page + self.file_src_input.setText(job.get('source', '')) + # self.archive_check removed, handled by global below + + # Load Global Compression + if job['type'] == 0: + self.global_compress_check.setChecked(job.get('zip', True)) + else: + self.global_compress_check.setChecked(job.get('compress', False)) + + self.retention_spin.setValue(job.get('retention_days', 0)) + + # Schedule + self.job_sched_enable.setChecked(job.get('schedule_enabled', False)) + self.job_sched_type.setCurrentIndex(job.get('schedule_type', 0)) + + # Interval + self.job_interval_spin.setValue(job.get('schedule_interval_val', 60)) + self.job_interval_unit.setCurrentText(job.get('schedule_interval_unit', 'Menit')) + + # Daily + t_str = job.get('schedule_time', '00:00') + self.job_time_edit.setTime(QTime.fromString(t_str, "HH:mm")) + + self.toggle_job_schedule(self.job_sched_enable.isChecked()) + + # DB Pages (Reusable helper) + def pop_db(page, prefix): + page.host_input.setText(job.get('host', 'localhost')) + # Defaults per type if missing? + if job['type'] == 1: def_port="3306"; def_user="root" + else: def_port="5432"; def_user="postgres" + + page.port_input.setText(job.get('port', def_port)) + page.user_input.setText(job.get('user', def_user)) + page.pass_input.setText(job.get('password', '')) + page.db_combo.setCurrentText(job.get('database', '')) + # page.compress_check removed + + + pop_db(self.page_mysql, '') + pop_db(self.page_postgres, '') + + # Load MySQL-specific options + if self.page_mysql.all_db_check: + all_db = job.get('all_databases', False) + self.page_mysql.all_db_check.setChecked(all_db) + self.page_mysql.db_combo.setEnabled(not all_db) + if self.page_mysql.skip_ssl_check: + self.page_mysql.skip_ssl_check.setChecked(job.get('skip_ssl', True)) + + # Load PostgreSQL-specific options + if self.page_postgres.all_db_check: + all_db = job.get('all_databases', False) + self.page_postgres.all_db_check.setChecked(all_db) + self.page_postgres.db_combo.setEnabled(not all_db) + + def auto_save_current_ui(self): + # We can trigger generic save or just wait for explicit button. + # Explicit button is safer to avoid lagging UI. + pass + + def save_current_job_to_list(self): + if not self.current_job_id: return + + job = next((j for j in self.jobs if j['id'] == self.current_job_id), None) + if not job: return + + # Update Dict + job['name'] = self.name_input.text() + job['type'] = self.type_combo.currentIndex() + job['dest'] = self.dest_input.text() + + job['source'] = self.file_src_input.text() + + # Global Compression + is_zip = self.global_compress_check.isChecked() + if job['type'] == 0: + job['zip'] = is_zip + else: + job['compress'] = is_zip + # Schedule + job['schedule_enabled'] = self.job_sched_enable.isChecked() + job['schedule_type'] = self.job_sched_type.currentIndex() + job['schedule_interval_val'] = self.job_interval_spin.value() + job['schedule_interval_unit'] = self.job_interval_unit.currentText() + job['schedule_time'] = self.job_time_edit.time().toString("HH:mm") + + job['retention_days'] = self.retention_spin.value() + + # Read from active page or all? All is fine, they share keys in this simple model, + # but better to read from the specific page. + if job['type'] == 1: # MySQL + job['host'] = self.page_mysql.host_input.text() + job['port'] = self.page_mysql.port_input.text() + job['user'] = self.page_mysql.user_input.text() + job['password'] = self.page_mysql.pass_input.text() + job['database'] = self.page_mysql.db_combo.currentText() + job['all_databases'] = self.page_mysql.all_db_check.isChecked() if self.page_mysql.all_db_check else False + job['skip_ssl'] = self.page_mysql.skip_ssl_check.isChecked() if self.page_mysql.skip_ssl_check else True + # job['compress'] handled globally + elif job['type'] == 2: # PG + job['host'] = self.page_postgres.host_input.text() + job['port'] = self.page_postgres.port_input.text() + job['user'] = self.page_postgres.user_input.text() + job['password'] = self.page_postgres.pass_input.text() + job['database'] = self.page_postgres.db_combo.currentText() + job['all_databases'] = self.page_postgres.all_db_check.isChecked() if self.page_postgres.all_db_check else False + # job['compress'] handled globally + + # Update List Item text + current_item = self.job_list.currentItem() + if current_item: + current_item.setText(job['name']) + + self.save_global_settings() + self.log(">>> Pekerjaan Diperbarui.") + + def save_global_settings(self): + # Build Config Dict + cfg = { + "jobs": self.jobs, + # Trial count moved to registry + } + ConfigManager.save_config(cfg) + self.apply_schedule() + + def load_settings(self): + cfg = ConfigManager.load_config() + self.jobs = cfg.get('jobs', []) + # Trial count loaded from registry in __init__ + + # Global schedule settings removed + + self.refresh_job_list() + self.apply_schedule() + + # --- Pages UI Helpers --- + def create_file_page(self): + page = QWidget() + layout = QVBoxLayout(page) + group = QGroupBox("Sumber File") + form = QVBoxLayout() + src_layout = QHBoxLayout() + self.file_src_input = QLineEdit() + self.file_src_input.setPlaceholderText("Pilih file atau folder...") + browse_src_btn = QPushButton("Jelajahi") + browse_src_btn.setObjectName("Secondary") + browse_src_btn.clicked.connect(self.browse_source) + src_layout.addWidget(self.file_src_input) + src_layout.addWidget(browse_src_btn) + # self.archive_check moved to global + form.addLayout(src_layout) + # form.addWidget(self.archive_check) + group.setLayout(form) + layout.addWidget(group) + layout.addStretch() + return page + + def create_db_page(self, db_type): + page = QWidget() + layout = QVBoxLayout(page) + group = QGroupBox("{} Credentials".format(db_type)) + form = QVBoxLayout() + row1 = QHBoxLayout() + host_input = QLineEdit() + host_input.setPlaceholderText("Host") + port_input = QLineEdit() + port_input.setPlaceholderText("Port") + port_input.setFixedWidth(80) + row1.addWidget(QLabel("Host:")) + row1.addWidget(host_input) + row1.addWidget(QLabel("Port:")) + row1.addWidget(port_input) + row2 = QHBoxLayout() + user_input = QLineEdit() + user_input.setPlaceholderText("Username") + pass_input = QLineEdit() + pass_input.setPlaceholderText("Password") + pass_input.setEchoMode(QLineEdit.Password) + row2.addWidget(QLabel("User:")) + row2.addWidget(user_input) + row2.addWidget(QLabel("Pass:")) + row2.addWidget(pass_input) + + # Server version row with refresh button + row_version = QHBoxLayout() + version_label = QLabel("Server: -") + version_label.setStyleSheet("color: #8be9fd; font-style: italic;") + refresh_btn = QPushButton("Refresh") + refresh_btn.setFixedWidth(80) + refresh_btn.setToolTip("Cek koneksi dan ambil daftar database") + row_version.addWidget(version_label) + row_version.addStretch() + row_version.addWidget(refresh_btn) + + # Database selection row with QComboBox + row3 = QHBoxLayout() + db_combo = QComboBox() + db_combo.setEditable(True) # Allow manual input too + # db_combo.setPlaceholderText("Database Name") # Removed for XP/PyQt5.5 compatibility + db_combo.setMinimumWidth(200) + row3.addWidget(QLabel("DB Name:")) + row3.addWidget(db_combo) + + form.addLayout(row1) + form.addLayout(row2) + form.addLayout(row_version) + form.addLayout(row3) + + # All databases option - for MySQL and PostgreSQL + all_db_check = None + skip_ssl_check = None + if db_type == "MySQL": + # All databases checkbox + all_db_check = QCheckBox("Semua Database (--all-databases)") + all_db_check.setToolTip("Backup semua database sekaligus") + all_db_check.setChecked(False) + # Disable DB combo when checked + all_db_check.toggled.connect(lambda checked: db_combo.setEnabled(not checked)) + form.addWidget(all_db_check) + + # Skip SSL option + skip_ssl_check = QCheckBox("Lewati SSL") + skip_ssl_check.setToolTip("Centang jika server MySQL tidak mendukung SSL") + skip_ssl_check.setChecked(True) # Default: skip SSL + form.addWidget(skip_ssl_check) + + # Connect refresh button for MySQL + refresh_btn.clicked.connect(lambda: self.refresh_mysql_databases(page)) + else: + # PostgreSQL also supports all databases via pg_dumpall + all_db_check = QCheckBox("Semua Database (pg_dumpall)") + all_db_check.setToolTip("Backup semua database sekaligus menggunakan pg_dumpall") + all_db_check.setChecked(False) + # Disable DB combo when checked + all_db_check.toggled.connect(lambda checked: db_combo.setEnabled(not checked)) + form.addWidget(all_db_check) + + # Connect refresh button for PostgreSQL + refresh_btn.clicked.connect(lambda: self.refresh_postgres_databases(page)) + + group.setLayout(form) + layout.addWidget(group) + layout.addStretch() + page.host_input = host_input + page.port_input = port_input + page.user_input = user_input + page.pass_input = pass_input + page.db_combo = db_combo # Changed from db_input to db_combo + page.version_label = version_label + page.all_db_check = all_db_check # Now available for both MySQL and PostgreSQL + page.skip_ssl_check = skip_ssl_check # Will be None for PostgreSQL + return page + + def on_type_changed(self, index): + self.stack.setCurrentIndex(index) + + def refresh_mysql_databases(self, page): + """Fetch MySQL server version and database list.""" + from backup_manager import get_mysql_info + + host = page.host_input.text() or 'localhost' + port = page.port_input.text() or '3306' + user = page.user_input.text() or 'root' + password = page.pass_input.text() + skip_ssl = page.skip_ssl_check.isChecked() if page.skip_ssl_check else True + + page.version_label.setText("Server: Menghubungkan...") + page.version_label.repaint() # Force UI update + + version, databases, error = get_mysql_info(host, port, user, password, skip_ssl) + + if error: + page.version_label.setText("Server: Error!") + page.version_label.setStyleSheet("color: #ff5555; font-style: italic;") + QMessageBox.warning(self, "Koneksi Gagal", "Tidak dapat terhubung ke MySQL:\n{}".format(error)) + else: + page.version_label.setText("Server: MySQL {}".format(version)) + page.version_label.setStyleSheet("color: #50fa7b; font-style: italic;") + + # Populate combo box + current_text = page.db_combo.currentText() + page.db_combo.clear() + page.db_combo.addItems(databases) + + # Try to restore previous selection + if current_text: + idx = page.db_combo.findText(current_text) + if idx >= 0: + page.db_combo.setCurrentIndex(idx) + else: + page.db_combo.setCurrentText(current_text) + + self.log(">>> MySQL {}: Ditemukan {} database".format(version, len(databases))) + + def refresh_postgres_databases(self, page): + """Fetch PostgreSQL server version and database list.""" + from backup_manager import get_postgres_info + + host = page.host_input.text() or 'localhost' + port = page.port_input.text() or '5432' + user = page.user_input.text() or 'postgres' + password = page.pass_input.text() + + page.version_label.setText("Server: Menghubungkan...") + page.version_label.repaint() # Force UI update + + version, databases, error = get_postgres_info(host, port, user, password) + + if error: + page.version_label.setText("Server: Error!") + page.version_label.setStyleSheet("color: #ff5555; font-style: italic;") + QMessageBox.warning(self, "Koneksi Gagal", "Tidak dapat terhubung ke PostgreSQL:\n{}".format(error)) + else: + page.version_label.setText("Server: {}".format(version)) + page.version_label.setStyleSheet("color: #50fa7b; font-style: italic;") + + # Populate combo box + current_text = page.db_combo.currentText() + page.db_combo.clear() + page.db_combo.addItems(databases) + + # Try to restore previous selection + if current_text: + idx = page.db_combo.findText(current_text) + if idx >= 0: + page.db_combo.setCurrentIndex(idx) + else: + page.db_combo.setCurrentText(current_text) + + self.log(">>> {}: Ditemukan {} database".format(version, len(databases))) + + def browse_source(self): + dir_path = QFileDialog.getExistingDirectory(self, "Pilih Direktori Sumber") + if dir_path: + self.file_src_input.setText(dir_path) + + def toggle_job_schedule(self, checked): + self.job_sched_type.setEnabled(checked) + self.sched_stack.setEnabled(checked) + + def on_sched_type_changed(self, index): + self.sched_stack.setCurrentIndex(index) + + def browse_dest(self): + dir_path = QFileDialog.getExistingDirectory(self, "Pilih Direktori Tujuan") + if dir_path: + self.dest_input.setText(dir_path) + + def open_backup_folder(self): + """Open backup destination folder in File Explorer.""" + dest_path = self.dest_input.text().strip() + if dest_path and os.path.exists(dest_path): + os.startfile(dest_path) + elif dest_path: + QMessageBox.warning(self, "Folder Tidak Ditemukan", + "Folder tidak ditemukan:\n{}".format(dest_path)) + else: + QMessageBox.information(self, "Tidak Ada Folder", + "Silakan pilih folder tujuan backup terlebih dahulu.") + + # --- Execution --- + def run_current_job(self): + if not self.current_job_id: + return + + job = next((j for j in self.jobs if j['id'] == self.current_job_id), None) + if not job: + return + + # Ensure latest edits are saved/used + # Ideally we should grab from UI, but for now let's assume user clicked 'Apply' + # or we force an update from UI to this job object before running? + # Let's simple use what's in self.jobs (which 'Apply' updates). + # But to be user friendly, let's call save_current_job_to_list first? + # That might change the list selection or order if not careful. + # Let's just use what is in jobs list to be safe and consistent. + + self.log(">>> Memulai Eksekusi Manual: {}".format(job['name'])) + + # --- Increment Usage REMOVED --- + + self.run_all_btn.setEnabled(False) + self.cancel_btn.setEnabled(True) + self.run_single_btn.setEnabled(False) + self.job_form_widget.setEnabled(False) # Lock editing + self.progress_bar.setValue(0) + + # Pass clear list with single job + self.worker = WorkerThread([job]) + self.worker.progress_signal.connect(self.update_progress) + self.worker.log_signal.connect(self.log) + self.worker.finished_signal.connect(self.processing_finished) + self.worker.error_signal.connect(self.processing_error) + self.worker.success_signal.connect(self.increment_trial_usage) # Count only on success + self.worker.start() + + def run_all_jobs(self, auto=False): + if not self.jobs: + self.log(">>> Tidak ada pekerjaan untuk dijalankan.") + return + + self.log(">>> Memulai Eksekusi Batch: {} Pekerjaan...".format(len(self.jobs))) + + self.run_all_btn.setEnabled(False) + self.cancel_btn.setEnabled(True) + self.progress_bar.setValue(0) + + # We pass the list of job dicts to the worker + self.worker = WorkerThread(self.jobs) + self.worker.progress_signal.connect(self.update_progress) + self.worker.log_signal.connect(self.log) + self.worker.finished_signal.connect(self.processing_finished) + self.worker.error_signal.connect(self.processing_error) + self.worker.success_signal.connect(self.increment_trial_usage) # Count only on success + self.worker.start() + + def apply_schedule(self): + self.scheduler.remove_all_jobs() + self.log(">>> Memperbarui jadwal...") + + count = 0 + for job in self.jobs: + if not job.get('schedule_enabled', False): + continue + + job_id = job.get('id') + job_name = job.get('name', 'Unknown') + sched_type = job.get('schedule_type', 0) + + trigger = None + desc = "" + + if sched_type == 0: # Interval + val = job.get('schedule_interval_val', 60) + unit = job.get('schedule_interval_unit', 'Menit') + seconds = val * 60 if unit == "Menit" else val * 3600 + trigger = IntervalTrigger(seconds=seconds) + desc = "Setiap {} {}".format(val, unit) + + elif sched_type == 1: # Daily + t_str = job.get('schedule_time', '00:00') + try: + h, m = map(int, t_str.split(':')) + trigger = CronTrigger(hour=h, minute=m) + desc = "Setiap Hari Pukul {}".format(t_str) + except ValueError: + self.log("!!! Format waktu salah untuk {}".format(job_name)) + continue + + if trigger: + # Use a default arg in lambda to capture job_id correctly in loop + self.scheduler.add_job( + self.trigger_backup_signal, + trigger, + args=[job_id], + id=str(job_id), + replace_existing=True + ) + count += 1 + self.log(" + Terjadwal: {} ({})".format(job_name, desc)) + + if count == 0: + self.log(">>> Tidak ada pekerjaan terjadwal yang aktif.") + else: + self.log(">>> {} pekerjaan telah dijadwalkan.".format(count)) + + def trigger_backup_signal(self, job_id): + self.start_backup_signal.emit(str(job_id)) + + def scheduled_backup_wrapper(self, job_id): + # Determine if we can run + if self.worker and self.worker.isRunning(): + # Concurrent jobs? For now let's skip or queue? + # Simple approach: Skip if busy. + self.log(">>> Jadwal Skip (App Sibuk) untuk Job ID: {}".format(job_id)) + return + + # --- Check Trial Limit --- + if not self.check_trial_limit_ok(): + self.log(">>> Jadwal Skip (Limit Trial Tercapai) untuk Job ID: {}".format(job_id)) + return + + # Find job + job = next((j for j in self.jobs if str(j['id']) == str(job_id)), None) + if not job: + return + + self.log(">>> JADWAL OTOMATIS: Menjalankan {}".format(job['name'])) + + # --- Increment Usage REMOVED from here --- + + # Run it using the single runner logic mechanism + # But we need to be careful about UI state + # Since this is "background" but utilizing main thread UI for progress... + # We should simulate "Run" button click essentially, but safely. + + # Reuse Worker logic directly + self.run_all_btn.setEnabled(False) + self.cancel_btn.setEnabled(True) + if hasattr(self, 'run_single_btn'): + self.run_single_btn.setEnabled(False) + self.job_form_widget.setEnabled(False) + self.progress_bar.setValue(0) + + self.worker = WorkerThread([job]) + self.worker.progress_signal.connect(self.update_progress) + self.worker.log_signal.connect(self.log) + self.worker.finished_signal.connect(self.processing_finished) + self.worker.error_signal.connect(self.processing_error) + self.worker.success_signal.connect(self.increment_trial_usage) # Count only on success + self.worker.start() + + def cancel_backup(self): + if self.worker: + self.worker.cancel() + self.log(">>> Meminta Pembatalan...") + self.cancel_btn.setEnabled(False) + + # --- Utils --- + def log(self, message): + self.log_area.append(message) + sb = self.log_area.verticalScrollBar() + sb.setValue(sb.maximum()) + + def update_progress(self, val): + self.progress_bar.setValue(val) + + def processing_finished(self): + self.run_all_btn.setEnabled(True) + self.cancel_btn.setEnabled(False) + self.job_form_widget.setEnabled(True) # Unlock + if hasattr(self, 'run_single_btn'): + self.run_single_btn.setEnabled(True) + self.log(">>> Eksekusi Selesai.") + + def processing_error(self, error_msg): + self.run_all_btn.setEnabled(True) + self.cancel_btn.setEnabled(False) + self.job_form_widget.setEnabled(True) + if hasattr(self, 'run_single_btn'): + self.run_single_btn.setEnabled(True) + self.log("!!! Kesalahan Thread: {}".format(error_msg)) + + # --- Tray --- + def setup_tray(self): + self.tray_icon = QSystemTrayIcon(self) + + # Use custom icon + icon_path = os.path.join(os.path.dirname(__file__), "icon.png") + if os.path.exists(icon_path): + icon = QIcon(icon_path) + else: + style = self.style() + icon = style.standardIcon(QStyle.SP_DriveHDIcon) + self.tray_icon.setIcon(icon) + + tray_menu = QMenu() + + # Status label + if self.trial_mode: + status_text = "Status: Mode Trial" + else: + status_text = "Status: Teraktivasi ✓" + self.status_action = QAction(status_text, self) + self.status_action.setEnabled(False) # Read-only label + + show_action = QAction("Tampilkan", self) + show_action.triggered.connect(self.show_window) + + self.activate_action = QAction("Aktivasi...", self) + self.activate_action.triggered.connect(self.force_activation_dialog) + # Disable if already activated + if not self.trial_mode: + self.activate_action.setEnabled(False) + + quit_action = QAction("Keluar", self) + quit_action.triggered.connect(self.quit_app) + + tray_menu.addAction(self.status_action) + tray_menu.addSeparator() + tray_menu.addAction(show_action) + tray_menu.addAction(self.activate_action) + tray_menu.addSeparator() + tray_menu.addAction(quit_action) + self.tray_icon.setContextMenu(tray_menu) + self.tray_icon.activated.connect(self.on_tray_activated) + self.tray_icon.show() + + def closeEvent(self, event): + # Always minimize to tray if tray is visible + if self.tray_icon.isVisible(): + QMessageBox.information(self, "Aplikasi Diminimalkan", + "Aplikasi akan diminimalkan ke taskbar.\n" + "Proses backup akan berjalan di latar belakang.\n\n" + "Klik kanan ikon tray untuk membuka kembali atau keluar.") + self.hide() + event.ignore() + else: + try: self.scheduler.shutdown() + except: pass + event.accept() + + def show_window(self): + self.show() + self.raise_() + self.activateWindow() + + def quit_app(self): + try: self.scheduler.shutdown() + except: pass + QApplication.quit() + + def on_tray_activated(self, reason): + if reason == QSystemTrayIcon.Trigger: + self.show_window() + + def setup_single_instance_listener(self): + """Listen for SHOW signals from other instances.""" + import threading + + if not self.instance_socket: + return # No socket to listen on + + def listener_thread(): + try: + while True: + try: + conn, addr = self.instance_socket.accept() + data = conn.recv(1024) + if data == b'SHOW': + # Use QTimer to call show_window in main thread + QTimer.singleShot(0, self.show_window) + conn.close() + except: + pass + except: + pass + + # Start listener in background thread + t = threading.Thread(target=listener_thread, daemon=True) + t.start() + +if __name__ == "__main__": + # --- Single Instance Check using Socket --- + import socket + import threading + + LOCK_PORT = 47653 # Unique port for ProBackup + single_instance_socket = None + + def is_already_running(): + """Check if another instance is already running.""" + global single_instance_socket + try: + single_instance_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + single_instance_socket.bind(('127.0.0.1', LOCK_PORT)) + single_instance_socket.listen(1) + return False # We got the lock, no other instance + except socket.error: + return True # Port in use, another instance running + + def send_show_signal(): + """Send signal to existing instance to show its window.""" + try: + client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + client.connect(('127.0.0.1', LOCK_PORT)) + client.send(b'SHOW') + client.close() + except: + pass + + if is_already_running(): + # Another instance is running - send signal to show window + send_show_signal() + sys.exit(0) + + # Optional: Set App ID for Taskbar icon + try: + myappid = 'mycompany.backuptool.multijob.1.0' + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) + except: pass + + app = QApplication(sys.argv) + app.setQuitOnLastWindowClosed(False) # For tray icon + + # --- License Check --- + hwid = LicenseManager.get_hardware_id() + saved_key = LicenseManager.load_license() + + is_trial = False + + is_valid, status = LicenseManager.validate_license(saved_key, hwid) + + if not is_valid: + if status == 'both_changed' and saved_key: + # Had a valid key before, but both hardware changed + QMessageBox.critical(None, "Hardware Berubah", + "Motherboard DAN Processor terdeteksi berubah.\n\n" + "Lisensi tidak valid untuk hardware baru ini.\n" + "Silakan hubungi admin untuk aktivasi ulang.\n\n" + "WA: +62 817-0380-6655") + is_trial = True + else: + # Valid, but check if hardware partially changed + if status == 'mb_changed': + QMessageBox.warning(None, "Peringatan Hardware", + "Lisensi valid, namun terdeteksi MOTHERBOARD telah berubah.\n" + "Jika Anda mengganti hardware lagi, lisensi bisa tidak valid.\n\n" + "Disarankan untuk menghubungi admin untuk update lisensi.") + elif status == 'cpu_changed': + QMessageBox.warning(None, "Peringatan Hardware", + "Lisensi valid, namun terdeteksi PROCESSOR telah berubah.\n" + "Jika Anda mengganti hardware lagi, lisensi bisa tidak valid.\n\n" + "Disarankan untuk menghubungi admin untuk update lisensi.") + + # Launch App (in trial or full mode) + window = BackupApp(trial_mode=is_trial, instance_socket=single_instance_socket) + window.show() + sys.exit(app.exec_()) + diff --git a/python-3.4.4.msi b/python-3.4.4.msi new file mode 100644 index 0000000..6f6a9db Binary files /dev/null and b/python-3.4.4.msi differ diff --git a/registration_dialog.py b/registration_dialog.py new file mode 100644 index 0000000..f0bb979 --- /dev/null +++ b/registration_dialog.py @@ -0,0 +1,105 @@ +from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QLabel, QLineEdit, + QPushButton, QMessageBox, QApplication) +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QClipboard + +from license_manager import LicenseManager + +class RegistrationDialog(QDialog): + def __init__(self): + super().__init__() + self.setWindowTitle("Aktivasi ProBackup") + self.resize(400, 300) + self.setStyleSheet(""" + QDialog { background-color: #282a36; color: #f8f8f2; font-family: "Segoe UI"; } + QLabel { color: #f8f8f2; font-size: 14px; } + QLineEdit { background-color: #44475a; border: 1px solid #6272a4; padding: 8px; color: #f8f8f2; border-radius: 4px; } + QPushButton { background-color: #6272a4; color: white; border: none; padding: 10px; border-radius: 4px; font-weight: bold; } + QPushButton:hover { background-color: #bd93f9; } + """) + + self.hwid = LicenseManager.get_hardware_id() + self.verified = False + + self.setup_ui() + + def setup_ui(self): + layout = QVBoxLayout(self) + layout.setSpacing(15) + + # Title + title = QLabel("Aktivasi Produk Diperlukan") + title.setStyleSheet("font-size: 18px; font-weight: bold; color: #ff5555;") + title.setAlignment(Qt.AlignCenter) + layout.addWidget(title) + + # Info + info = QLabel("Untuk menggunakan aplikasi ini, silakan kirimkan ID Mesin di bawah ini ke administrator untuk mendapatkan Kunci Aktivasi.") + info.setWordWrap(True) + layout.addWidget(info) + + # HWID Display + layout.addWidget(QLabel("ID Mesin Anda:")) + + self.hwid_input = QLineEdit(self.hwid) + self.hwid_input.setReadOnly(True) + self.hwid_input.setStyleSheet("font-family: Consolas; font-size: 14px; color: #50fa7b;") + layout.addWidget(self.hwid_input) + + copy_btn = QPushButton("Salin ID Mesin") + copy_btn.clicked.connect(self.copy_hwid) + layout.addWidget(copy_btn) + + # Key Input + layout.addWidget(QLabel("Masukkan Kunci Aktivasi:")) + self.key_input = QLineEdit() + self.key_input.setPlaceholderText("Tempel kunci aktivasi disini...") + layout.addWidget(self.key_input) + + # Actions + activate_btn = QPushButton("AKTIFKAN") + activate_btn.setStyleSheet("background-color: #50fa7b; color: #282a36;") + activate_btn.clicked.connect(self.verify_key) + layout.addWidget(activate_btn) + + layout.addStretch() + + def copy_hwid(self): + cb = QApplication.clipboard() + cb.setText(self.hwid) + QMessageBox.information(self, "Disalin", "ID Mesin telah disalin ke clipboard.") + + def verify_key(self): + key = self.key_input.text().strip() + is_valid, status = LicenseManager.validate_license(key, self.hwid) + + if is_valid: + LicenseManager.save_license(key) + if status == 'mb_changed': + QMessageBox.warning(self, "Peringatan Hardware", + "Aktivasi berhasil, namun terdeteksi MOTHERBOARD telah berubah.\n" + "Jika Anda mengganti hardware lagi, lisensi bisa tidak valid.") + elif status == 'cpu_changed': + QMessageBox.warning(self, "Peringatan Hardware", + "Aktivasi berhasil, namun terdeteksi PROCESSOR telah berubah.\n" + "Jika Anda mengganti hardware lagi, lisensi bisa tidak valid.") + else: + QMessageBox.information(self, "Sukses", "Aktivasi Berhasil! Terima kasih.") + self.verified = True + self.accept() + else: + if status == 'both_changed': + QMessageBox.critical(self, "Hardware Berubah", + "Motherboard DAN Processor terdeteksi berubah.\n\n" + "Lisensi tidak valid untuk hardware baru ini.\n" + "Silakan hubungi admin untuk aktivasi ulang.\n\n" + "WA: +62 817-0380-6655") + else: + QMessageBox.warning(self, "Gagal", "Kunci Aktivasi Salah. Silakan coba lagi.") + + def closeEvent(self, event): + if not self.verified: + # If closed without verification, reject dialog + self.reject() + else: + event.accept() diff --git a/registry_manager.py b/registry_manager.py new file mode 100644 index 0000000..759c701 --- /dev/null +++ b/registry_manager.py @@ -0,0 +1,52 @@ +import winreg +import hashlib +from license_manager import LicenseManager + +class RegistryManager: + # We will generate a path based on HWID hash to obscure it + # Format: Software\{SHA256_of_HWID_First_16_Chars} + + @staticmethod + def _get_reg_path(): + hwid = LicenseManager.get_hardware_id() + # Create a hash of the HWID to make it look like a random system key + path_hash = hashlib.md5("WartanaProBackup_{}".format(hwid).encode()).hexdigest().upper() + return "SOFTWARE\\{}".format(path_hash) + + @staticmethod + def get_trial_count(): + try: + path = RegistryManager._get_reg_path() + # Open Key + key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, path, 0, winreg.KEY_READ) + value, regtype = winreg.QueryValueEx(key, "SystemState") # Obscured value name too + winreg.CloseKey(key) + return int(value) + except WindowsError: + # Key not found, return 0 + return 0 + except Exception: + return 3 # Fail safe -> block if error + + @staticmethod + def increment_trial_count(): + try: + path = RegistryManager._get_reg_path() + # Create key if not exists + key = winreg.CreateKey(winreg.HKEY_CURRENT_USER, path) + + # Read current + current = 0 + try: + val, _ = winreg.QueryValueEx(key, "SystemState") + current = int(val) + except: pass + + # Increment + new_val = current + 1 + winreg.SetValueEx(key, "SystemState", 0, winreg.REG_DWORD, new_val) + winreg.CloseKey(key) + return new_val + except Exception as e: + print("Reg Error: {}".format(e)) + return 3 # Fail safe diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f0a8869 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +PyQt5==5.6 +pymysql==0.9.3 +psycopg2==2.7.7 +apscheduler==3.6.3 +pyinstaller==3.5 +# cryptography removed due to compilation issues on win7/py3.4 diff --git a/rthook_qt_fix.py b/rthook_qt_fix.py new file mode 100644 index 0000000..4ed8ee0 --- /dev/null +++ b/rthook_qt_fix.py @@ -0,0 +1,26 @@ +import os +import sys + +# Fixing Qt Platform Plugin "windows" error for PyInstaller OneFile +# This runs BEFORE main.py + +def install_qt_plugin_path(): + # If running in a PyInstaller bundle + if getattr(sys, 'frozen', False): + # sys._MEIPASS is where PyInstaller unpacks the bundle + base_path = getattr(sys, '_MEIPASS', os.path.dirname(sys.executable)) + + # We bundled paths via "a.datas += Tree(..., prefix='platforms')" + # So it should be at base_path/platforms + platform_plugin_path = os.path.join(base_path, 'platforms') + + print("runtime_hook: Detected base path: {}".format(base_path)) + print("runtime_hook: Setting QT_QPA_PLATFORM_PLUGIN_PATH to: {}".format(platform_plugin_path)) + + # Force Qt to look here + os.environ['QT_QPA_PLATFORM_PLUGIN_PATH'] = platform_plugin_path + + # Also ensure general plugin path is set, just in case + os.environ['QT_PLUGIN_PATH'] = base_path + +install_qt_plugin_path() diff --git a/sql_tools/libcrypto-1_1-x64.dll b/sql_tools/libcrypto-1_1-x64.dll new file mode 100644 index 0000000..4d1399b Binary files /dev/null and b/sql_tools/libcrypto-1_1-x64.dll differ diff --git a/sql_tools/libiconv-2.dll b/sql_tools/libiconv-2.dll new file mode 100644 index 0000000..12a27a9 Binary files /dev/null and b/sql_tools/libiconv-2.dll differ diff --git a/sql_tools/libintl-9.dll b/sql_tools/libintl-9.dll new file mode 100644 index 0000000..82ffaad Binary files /dev/null and b/sql_tools/libintl-9.dll differ diff --git a/sql_tools/libpq.dll b/sql_tools/libpq.dll new file mode 100644 index 0000000..c1dabf6 Binary files /dev/null and b/sql_tools/libpq.dll differ diff --git a/sql_tools/libssl-1_1-x64.dll b/sql_tools/libssl-1_1-x64.dll new file mode 100644 index 0000000..48db014 Binary files /dev/null and b/sql_tools/libssl-1_1-x64.dll differ diff --git a/sql_tools/libwinpthread-1.dll b/sql_tools/libwinpthread-1.dll new file mode 100644 index 0000000..2d5ecc2 Binary files /dev/null and b/sql_tools/libwinpthread-1.dll differ diff --git a/sql_tools/mariadb/10.11/mariadb-dump.exe b/sql_tools/mariadb/10.11/mariadb-dump.exe new file mode 100644 index 0000000..3527068 Binary files /dev/null and b/sql_tools/mariadb/10.11/mariadb-dump.exe differ diff --git a/sql_tools/mariadb/10.11/mysqldump.exe b/sql_tools/mariadb/10.11/mysqldump.exe new file mode 100644 index 0000000..3527068 Binary files /dev/null and b/sql_tools/mariadb/10.11/mysqldump.exe differ diff --git a/sql_tools/mariadb/10.6/mariadb-dump.exe b/sql_tools/mariadb/10.6/mariadb-dump.exe new file mode 100644 index 0000000..d8a2ef2 Binary files /dev/null and b/sql_tools/mariadb/10.6/mariadb-dump.exe differ diff --git a/sql_tools/mariadb/10.6/mysqldump.exe b/sql_tools/mariadb/10.6/mysqldump.exe new file mode 100644 index 0000000..d8a2ef2 Binary files /dev/null and b/sql_tools/mariadb/10.6/mysqldump.exe differ diff --git a/sql_tools/mariadb/11.8/mariadb-dump.exe b/sql_tools/mariadb/11.8/mariadb-dump.exe new file mode 100644 index 0000000..2329be4 Binary files /dev/null and b/sql_tools/mariadb/11.8/mariadb-dump.exe differ diff --git a/sql_tools/mariadb/11.8/mysqldump.exe b/sql_tools/mariadb/11.8/mysqldump.exe new file mode 100644 index 0000000..2329be4 Binary files /dev/null and b/sql_tools/mariadb/11.8/mysqldump.exe differ diff --git a/sql_tools/mysql/5.7/mysqldump.exe b/sql_tools/mysql/5.7/mysqldump.exe new file mode 100644 index 0000000..931957e Binary files /dev/null and b/sql_tools/mysql/5.7/mysqldump.exe differ diff --git a/sql_tools/mysql/8.0/mysqldump.exe b/sql_tools/mysql/8.0/mysqldump.exe new file mode 100644 index 0000000..a0cfd1d Binary files /dev/null and b/sql_tools/mysql/8.0/mysqldump.exe differ diff --git a/sql_tools/mysql/8.4/mysqldump.exe b/sql_tools/mysql/8.4/mysqldump.exe new file mode 100644 index 0000000..bbdd84f Binary files /dev/null and b/sql_tools/mysql/8.4/mysqldump.exe differ diff --git a/sql_tools/mysql/9.4/mysqldump.exe b/sql_tools/mysql/9.4/mysqldump.exe new file mode 100644 index 0000000..a336a86 Binary files /dev/null and b/sql_tools/mysql/9.4/mysqldump.exe differ diff --git a/sql_tools/mysql/9.5/mysqldump.exe b/sql_tools/mysql/9.5/mysqldump.exe new file mode 100644 index 0000000..3224585 Binary files /dev/null and b/sql_tools/mysql/9.5/mysqldump.exe differ diff --git a/sql_tools/mysqldump.exe b/sql_tools/mysqldump.exe new file mode 100644 index 0000000..1e289bd Binary files /dev/null and b/sql_tools/mysqldump.exe differ diff --git a/sql_tools/pg_dump.exe b/sql_tools/pg_dump.exe new file mode 100644 index 0000000..e1a2727 Binary files /dev/null and b/sql_tools/pg_dump.exe differ diff --git a/sql_tools/pg_dumpall.exe b/sql_tools/pg_dumpall.exe new file mode 100644 index 0000000..672fe68 Binary files /dev/null and b/sql_tools/pg_dumpall.exe differ diff --git a/sql_tools/postgres/18.1/icudt77.dll b/sql_tools/postgres/18.1/icudt77.dll new file mode 100644 index 0000000..4914ffa Binary files /dev/null and b/sql_tools/postgres/18.1/icudt77.dll differ diff --git a/sql_tools/postgres/18.1/icuin77.dll b/sql_tools/postgres/18.1/icuin77.dll new file mode 100644 index 0000000..d910100 Binary files /dev/null and b/sql_tools/postgres/18.1/icuin77.dll differ diff --git a/sql_tools/postgres/18.1/icuio77.dll b/sql_tools/postgres/18.1/icuio77.dll new file mode 100644 index 0000000..30ca22c Binary files /dev/null and b/sql_tools/postgres/18.1/icuio77.dll differ diff --git a/sql_tools/postgres/18.1/icutu77.dll b/sql_tools/postgres/18.1/icutu77.dll new file mode 100644 index 0000000..599f3f5 Binary files /dev/null and b/sql_tools/postgres/18.1/icutu77.dll differ diff --git a/sql_tools/postgres/18.1/icuuc77.dll b/sql_tools/postgres/18.1/icuuc77.dll new file mode 100644 index 0000000..552b652 Binary files /dev/null and b/sql_tools/postgres/18.1/icuuc77.dll differ diff --git a/sql_tools/postgres/18.1/libcrypto-3-x64.dll b/sql_tools/postgres/18.1/libcrypto-3-x64.dll new file mode 100644 index 0000000..66be2f9 Binary files /dev/null and b/sql_tools/postgres/18.1/libcrypto-3-x64.dll differ diff --git a/sql_tools/postgres/18.1/libcurl.dll b/sql_tools/postgres/18.1/libcurl.dll new file mode 100644 index 0000000..c1b68f5 Binary files /dev/null and b/sql_tools/postgres/18.1/libcurl.dll differ diff --git a/sql_tools/postgres/18.1/libecpg.dll b/sql_tools/postgres/18.1/libecpg.dll new file mode 100644 index 0000000..cd449ff Binary files /dev/null and b/sql_tools/postgres/18.1/libecpg.dll differ diff --git a/sql_tools/postgres/18.1/libecpg_compat.dll b/sql_tools/postgres/18.1/libecpg_compat.dll new file mode 100644 index 0000000..0efc0aa Binary files /dev/null and b/sql_tools/postgres/18.1/libecpg_compat.dll differ diff --git a/sql_tools/postgres/18.1/libiconv-2.dll b/sql_tools/postgres/18.1/libiconv-2.dll new file mode 100644 index 0000000..bc0e0c7 Binary files /dev/null and b/sql_tools/postgres/18.1/libiconv-2.dll differ diff --git a/sql_tools/postgres/18.1/libintl-9.dll b/sql_tools/postgres/18.1/libintl-9.dll new file mode 100644 index 0000000..82ffaad Binary files /dev/null and b/sql_tools/postgres/18.1/libintl-9.dll differ diff --git a/sql_tools/postgres/18.1/liblz4.dll b/sql_tools/postgres/18.1/liblz4.dll new file mode 100644 index 0000000..fd9be4c Binary files /dev/null and b/sql_tools/postgres/18.1/liblz4.dll differ diff --git a/sql_tools/postgres/18.1/libpgtypes.dll b/sql_tools/postgres/18.1/libpgtypes.dll new file mode 100644 index 0000000..11e5545 Binary files /dev/null and b/sql_tools/postgres/18.1/libpgtypes.dll differ diff --git a/sql_tools/postgres/18.1/libpq.dll b/sql_tools/postgres/18.1/libpq.dll new file mode 100644 index 0000000..5967511 Binary files /dev/null and b/sql_tools/postgres/18.1/libpq.dll differ diff --git a/sql_tools/postgres/18.1/libssl-3-x64.dll b/sql_tools/postgres/18.1/libssl-3-x64.dll new file mode 100644 index 0000000..229426b Binary files /dev/null and b/sql_tools/postgres/18.1/libssl-3-x64.dll differ diff --git a/sql_tools/postgres/18.1/libwinpthread-1.dll b/sql_tools/postgres/18.1/libwinpthread-1.dll new file mode 100644 index 0000000..2d5ecc2 Binary files /dev/null and b/sql_tools/postgres/18.1/libwinpthread-1.dll differ diff --git a/sql_tools/postgres/18.1/libxml2.dll b/sql_tools/postgres/18.1/libxml2.dll new file mode 100644 index 0000000..5650bb2 Binary files /dev/null and b/sql_tools/postgres/18.1/libxml2.dll differ diff --git a/sql_tools/postgres/18.1/libxslt.dll b/sql_tools/postgres/18.1/libxslt.dll new file mode 100644 index 0000000..71e82cf Binary files /dev/null and b/sql_tools/postgres/18.1/libxslt.dll differ diff --git a/sql_tools/postgres/18.1/libzstd.dll b/sql_tools/postgres/18.1/libzstd.dll new file mode 100644 index 0000000..e113b6a Binary files /dev/null and b/sql_tools/postgres/18.1/libzstd.dll differ diff --git a/sql_tools/postgres/18.1/pg_dump.exe b/sql_tools/postgres/18.1/pg_dump.exe new file mode 100644 index 0000000..d5b9129 Binary files /dev/null and b/sql_tools/postgres/18.1/pg_dump.exe differ diff --git a/sql_tools/postgres/18.1/pg_dumpall.exe b/sql_tools/postgres/18.1/pg_dumpall.exe new file mode 100644 index 0000000..64c4e9c Binary files /dev/null and b/sql_tools/postgres/18.1/pg_dumpall.exe differ diff --git a/sql_tools/postgres/18.1/testplug.dll b/sql_tools/postgres/18.1/testplug.dll new file mode 100644 index 0000000..7631f59 Binary files /dev/null and b/sql_tools/postgres/18.1/testplug.dll differ diff --git a/sql_tools/postgres/18.1/wxbase328u_net_vc_x64_custom.dll b/sql_tools/postgres/18.1/wxbase328u_net_vc_x64_custom.dll new file mode 100644 index 0000000..56bee3d Binary files /dev/null and b/sql_tools/postgres/18.1/wxbase328u_net_vc_x64_custom.dll differ diff --git a/sql_tools/postgres/18.1/wxbase328u_vc_x64_custom.dll b/sql_tools/postgres/18.1/wxbase328u_vc_x64_custom.dll new file mode 100644 index 0000000..60dd7d3 Binary files /dev/null and b/sql_tools/postgres/18.1/wxbase328u_vc_x64_custom.dll differ diff --git a/sql_tools/postgres/18.1/wxbase328u_xml_vc_x64_custom.dll b/sql_tools/postgres/18.1/wxbase328u_xml_vc_x64_custom.dll new file mode 100644 index 0000000..06df7b6 Binary files /dev/null and b/sql_tools/postgres/18.1/wxbase328u_xml_vc_x64_custom.dll differ diff --git a/sql_tools/postgres/18.1/wxmsw328u_adv_vc_x64_custom.dll b/sql_tools/postgres/18.1/wxmsw328u_adv_vc_x64_custom.dll new file mode 100644 index 0000000..f62e20d Binary files /dev/null and b/sql_tools/postgres/18.1/wxmsw328u_adv_vc_x64_custom.dll differ diff --git a/sql_tools/postgres/18.1/wxmsw328u_aui_vc_x64_custom.dll b/sql_tools/postgres/18.1/wxmsw328u_aui_vc_x64_custom.dll new file mode 100644 index 0000000..b70f476 Binary files /dev/null and b/sql_tools/postgres/18.1/wxmsw328u_aui_vc_x64_custom.dll differ diff --git a/sql_tools/postgres/18.1/wxmsw328u_core_vc_x64_custom.dll b/sql_tools/postgres/18.1/wxmsw328u_core_vc_x64_custom.dll new file mode 100644 index 0000000..cac7ca6 Binary files /dev/null and b/sql_tools/postgres/18.1/wxmsw328u_core_vc_x64_custom.dll differ diff --git a/sql_tools/postgres/18.1/wxmsw328u_html_vc_x64_custom.dll b/sql_tools/postgres/18.1/wxmsw328u_html_vc_x64_custom.dll new file mode 100644 index 0000000..0c74c07 Binary files /dev/null and b/sql_tools/postgres/18.1/wxmsw328u_html_vc_x64_custom.dll differ diff --git a/sql_tools/postgres/18.1/wxmsw328u_xrc_vc_x64_custom.dll b/sql_tools/postgres/18.1/wxmsw328u_xrc_vc_x64_custom.dll new file mode 100644 index 0000000..c4ca91b Binary files /dev/null and b/sql_tools/postgres/18.1/wxmsw328u_xrc_vc_x64_custom.dll differ diff --git a/sql_tools/postgres/18.1/zlib1.dll b/sql_tools/postgres/18.1/zlib1.dll new file mode 100644 index 0000000..e847044 Binary files /dev/null and b/sql_tools/postgres/18.1/zlib1.dll differ diff --git a/sql_tools/postgres/9.6/libcrypto-1_1-x64.dll b/sql_tools/postgres/9.6/libcrypto-1_1-x64.dll new file mode 100644 index 0000000..1f9eba4 Binary files /dev/null and b/sql_tools/postgres/9.6/libcrypto-1_1-x64.dll differ diff --git a/sql_tools/postgres/9.6/libcurl.dll b/sql_tools/postgres/9.6/libcurl.dll new file mode 100644 index 0000000..2b16e53 Binary files /dev/null and b/sql_tools/postgres/9.6/libcurl.dll differ diff --git a/sql_tools/postgres/9.6/libecpg.dll b/sql_tools/postgres/9.6/libecpg.dll new file mode 100644 index 0000000..f3746e3 Binary files /dev/null and b/sql_tools/postgres/9.6/libecpg.dll differ diff --git a/sql_tools/postgres/9.6/libecpg_compat.dll b/sql_tools/postgres/9.6/libecpg_compat.dll new file mode 100644 index 0000000..a15e176 Binary files /dev/null and b/sql_tools/postgres/9.6/libecpg_compat.dll differ diff --git a/sql_tools/postgres/9.6/libiconv-2.dll b/sql_tools/postgres/9.6/libiconv-2.dll new file mode 100644 index 0000000..1737b04 Binary files /dev/null and b/sql_tools/postgres/9.6/libiconv-2.dll differ diff --git a/sql_tools/postgres/9.6/libintl-8.dll b/sql_tools/postgres/9.6/libintl-8.dll new file mode 100644 index 0000000..f27a434 Binary files /dev/null and b/sql_tools/postgres/9.6/libintl-8.dll differ diff --git a/sql_tools/postgres/9.6/libpgtypes.dll b/sql_tools/postgres/9.6/libpgtypes.dll new file mode 100644 index 0000000..6a45f65 Binary files /dev/null and b/sql_tools/postgres/9.6/libpgtypes.dll differ diff --git a/sql_tools/postgres/9.6/libpq.dll b/sql_tools/postgres/9.6/libpq.dll new file mode 100644 index 0000000..a7bf5b4 Binary files /dev/null and b/sql_tools/postgres/9.6/libpq.dll differ diff --git a/sql_tools/postgres/9.6/libssl-1_1-x64.dll b/sql_tools/postgres/9.6/libssl-1_1-x64.dll new file mode 100644 index 0000000..8e78d46 Binary files /dev/null and b/sql_tools/postgres/9.6/libssl-1_1-x64.dll differ diff --git a/sql_tools/postgres/9.6/libxml2.dll b/sql_tools/postgres/9.6/libxml2.dll new file mode 100644 index 0000000..11a469a Binary files /dev/null and b/sql_tools/postgres/9.6/libxml2.dll differ diff --git a/sql_tools/postgres/9.6/libxslt.dll b/sql_tools/postgres/9.6/libxslt.dll new file mode 100644 index 0000000..dc291df Binary files /dev/null and b/sql_tools/postgres/9.6/libxslt.dll differ diff --git a/sql_tools/postgres/9.6/pg_dump.exe b/sql_tools/postgres/9.6/pg_dump.exe new file mode 100644 index 0000000..9c4aa61 Binary files /dev/null and b/sql_tools/postgres/9.6/pg_dump.exe differ diff --git a/sql_tools/postgres/9.6/pg_dumpall.exe b/sql_tools/postgres/9.6/pg_dumpall.exe new file mode 100644 index 0000000..ab0d283 Binary files /dev/null and b/sql_tools/postgres/9.6/pg_dumpall.exe differ diff --git a/sql_tools/postgres/9.6/wxbase28u_net_vc_custom.dll b/sql_tools/postgres/9.6/wxbase28u_net_vc_custom.dll new file mode 100644 index 0000000..74f58f3 Binary files /dev/null and b/sql_tools/postgres/9.6/wxbase28u_net_vc_custom.dll differ diff --git a/sql_tools/postgres/9.6/wxbase28u_vc_custom.dll b/sql_tools/postgres/9.6/wxbase28u_vc_custom.dll new file mode 100644 index 0000000..1deaaf7 Binary files /dev/null and b/sql_tools/postgres/9.6/wxbase28u_vc_custom.dll differ diff --git a/sql_tools/postgres/9.6/wxbase28u_xml_vc_custom.dll b/sql_tools/postgres/9.6/wxbase28u_xml_vc_custom.dll new file mode 100644 index 0000000..c295c0c Binary files /dev/null and b/sql_tools/postgres/9.6/wxbase28u_xml_vc_custom.dll differ diff --git a/sql_tools/postgres/9.6/wxmsw28u_adv_vc_custom.dll b/sql_tools/postgres/9.6/wxmsw28u_adv_vc_custom.dll new file mode 100644 index 0000000..f024408 Binary files /dev/null and b/sql_tools/postgres/9.6/wxmsw28u_adv_vc_custom.dll differ diff --git a/sql_tools/postgres/9.6/wxmsw28u_aui_vc_custom.dll b/sql_tools/postgres/9.6/wxmsw28u_aui_vc_custom.dll new file mode 100644 index 0000000..de26dd9 Binary files /dev/null and b/sql_tools/postgres/9.6/wxmsw28u_aui_vc_custom.dll differ diff --git a/sql_tools/postgres/9.6/wxmsw28u_core_vc_custom.dll b/sql_tools/postgres/9.6/wxmsw28u_core_vc_custom.dll new file mode 100644 index 0000000..9a7c0e1 Binary files /dev/null and b/sql_tools/postgres/9.6/wxmsw28u_core_vc_custom.dll differ diff --git a/sql_tools/postgres/9.6/wxmsw28u_html_vc_custom.dll b/sql_tools/postgres/9.6/wxmsw28u_html_vc_custom.dll new file mode 100644 index 0000000..7bca895 Binary files /dev/null and b/sql_tools/postgres/9.6/wxmsw28u_html_vc_custom.dll differ diff --git a/sql_tools/postgres/9.6/wxmsw28u_stc_vc_custom.dll b/sql_tools/postgres/9.6/wxmsw28u_stc_vc_custom.dll new file mode 100644 index 0000000..bb75b1b Binary files /dev/null and b/sql_tools/postgres/9.6/wxmsw28u_stc_vc_custom.dll differ diff --git a/sql_tools/postgres/9.6/wxmsw28u_xrc_vc_custom.dll b/sql_tools/postgres/9.6/wxmsw28u_xrc_vc_custom.dll new file mode 100644 index 0000000..f560d6c Binary files /dev/null and b/sql_tools/postgres/9.6/wxmsw28u_xrc_vc_custom.dll differ diff --git a/sql_tools/postgres/9.6/zlib1.dll b/sql_tools/postgres/9.6/zlib1.dll new file mode 100644 index 0000000..c87675c Binary files /dev/null and b/sql_tools/postgres/9.6/zlib1.dll differ diff --git a/sql_tools/zlib1.dll b/sql_tools/zlib1.dll new file mode 100644 index 0000000..e847044 Binary files /dev/null and b/sql_tools/zlib1.dll differ diff --git a/vcredist_x86.exe b/vcredist_x86.exe new file mode 100644 index 0000000..ba3562e Binary files /dev/null and b/vcredist_x86.exe differ