feat: Introduce initial application structure, SQL database tools, and license management.

This commit is contained in:
2026-01-29 23:07:55 +08:00
commit 8319916a06
97 changed files with 3207 additions and 0 deletions

32
.gitignore vendored Normal file
View File

@@ -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

36
Keygen.spec Normal file
View File

@@ -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',
)

108
ProBackup.spec Normal file
View File

@@ -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',
)

Binary file not shown.

679
backup_manager.py Normal file
View File

@@ -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

4
build_xp.bat Normal file
View File

@@ -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

119
config_manager.py Normal file
View File

@@ -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))

BIN
dist-winXP/ProBackup.exe Normal file

Binary file not shown.

118
extract_tools.py Normal file
View File

@@ -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()

24
fix_qt_paths.bat Normal file
View File

@@ -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

BIN
icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

118
install_deps_xp.bat Normal file
View File

@@ -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

104
keygen.py Normal file
View File

@@ -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("<<Cut>>"))
menu.add_command(label="Copy", command=lambda: widget.event_generate("<<Copy>>"))
menu.add_command(label="Paste", command=lambda: widget.event_generate("<<Paste>>"))
def show_menu(event):
menu.post(event.x_root, event.y_root)
widget.bind("<Button-3>", 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()

144
license_manager.py Normal file
View File

@@ -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

1532
main.py Normal file

File diff suppressed because it is too large Load Diff

BIN
python-3.4.4.msi Normal file

Binary file not shown.

105
registration_dialog.py Normal file
View File

@@ -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()

52
registry_manager.py Normal file
View File

@@ -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

6
requirements.txt Normal file
View File

@@ -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

26
rthook_qt_fix.py Normal file
View File

@@ -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()

Binary file not shown.

BIN
sql_tools/libiconv-2.dll Normal file

Binary file not shown.

BIN
sql_tools/libintl-9.dll Normal file

Binary file not shown.

BIN
sql_tools/libpq.dll Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
sql_tools/mysqldump.exe Normal file

Binary file not shown.

BIN
sql_tools/pg_dump.exe Normal file

Binary file not shown.

BIN
sql_tools/pg_dumpall.exe Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
sql_tools/zlib1.dll Normal file

Binary file not shown.

BIN
vcredist_x86.exe Normal file

Binary file not shown.