1646 lines
62 KiB
Python
1646 lines
62 KiB
Python
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, QTimeEdit)
|
|
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;
|
|
}
|
|
QMenuBar {
|
|
background-color: #282a36;
|
|
color: #f8f8f2;
|
|
}
|
|
QMenuBar::item {
|
|
background: transparent;
|
|
padding: 5px 10px;
|
|
}
|
|
QMenuBar::item:selected {
|
|
background: #44475a;
|
|
}
|
|
QMenu {
|
|
background-color: #282a36;
|
|
color: #f8f8f2;
|
|
border: 1px solid #6272a4;
|
|
}
|
|
QMenu::item {
|
|
padding: 8px 25px;
|
|
}
|
|
QMenu::item:selected {
|
|
background-color: #6272a4;
|
|
color: #f8f8f2;
|
|
}
|
|
"""
|
|
|
|
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.settings = self.load_settings()
|
|
self.trial_limit = 10 # Max uses in trial mode
|
|
|
|
# Job Queue System
|
|
self.job_queue = []
|
|
|
|
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:"))
|
|
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.setSuffix(" File")
|
|
self.retention_spin.setToolTip("Simpan X file backup terbaru. File yang lebih lama akan dihapus.")
|
|
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, "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()
|
|
# Add timestamp prefix as requested
|
|
# "kalau buat daftar pekerjaan baru, tambahkan prefix tanggal dan jam"
|
|
job['name'] = "{} {}".format(time.strftime("%Y-%m-%d %H:%M"), job['name'])
|
|
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? Queue them!
|
|
self.log(">>> App Sibuk. Job ID {} masuk antrian.".format(job_id))
|
|
self.job_queue.append(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.")
|
|
|
|
# Check Queue
|
|
if self.job_queue:
|
|
next_job_id = self.job_queue.pop(0)
|
|
self.log(">>> Memproses antrian: Job ID {}".format(next_job_id))
|
|
# Use QTimer to schedule it on main thread to be safe, though we are likely already on main thread slot
|
|
QTimer.singleShot(100, lambda: self.scheduled_backup_wrapper(next_job_id))
|
|
|
|
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))
|
|
|
|
# Check Queue even on error
|
|
if self.job_queue:
|
|
next_job_id = self.job_queue.pop(0)
|
|
self.log(">>> Memproses antrian (setelah error): Job ID {}".format(next_job_id))
|
|
QTimer.singleShot(100, lambda: self.scheduled_backup_wrapper(next_job_id))
|
|
|
|
# --- 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)
|
|
# Menu Bar
|
|
menubar = self.menuBar()
|
|
|
|
# File Menu
|
|
file_menu = menubar.addMenu('File')
|
|
exit_action = QAction('Keluar', self)
|
|
exit_action.triggered.connect(self.close)
|
|
file_menu.addAction(exit_action)
|
|
|
|
# Settings Menu (Pengaturan)
|
|
settings_menu = menubar.addMenu('Pengaturan')
|
|
|
|
# Auto Start Action
|
|
self.auto_start_action = QAction('Auto Start saat Windows', self)
|
|
self.auto_start_action.setCheckable(True)
|
|
self.auto_start_action.setChecked(self.check_auto_start_status())
|
|
self.auto_start_action.triggered.connect(self.toggle_auto_start)
|
|
settings_menu.addAction(self.auto_start_action)
|
|
|
|
# Help Menu
|
|
help_menu = menubar.addMenu('Bantuan')
|
|
about_action = QAction('Tentang', self)
|
|
about_action.triggered.connect(self.show_about)
|
|
help_menu.addAction(about_action)
|
|
|
|
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_about(self):
|
|
QMessageBox.about(self, "Tentang ProBackup XP",
|
|
"ProBackup XP Edition v1.0\n\n"
|
|
"Created for Legacy Systems.\n"
|
|
"Copyright 2026 Wartana.")
|
|
|
|
def check_auto_start_status(self):
|
|
"""Check if registry key exists for auto start"""
|
|
try:
|
|
import winreg
|
|
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Run", 0, winreg.KEY_READ)
|
|
winreg.QueryValueEx(key, "ProBackupXP")
|
|
winreg.CloseKey(key)
|
|
return True
|
|
except:
|
|
return False
|
|
|
|
def toggle_auto_start(self, checked):
|
|
"""Add or remove registry key for auto start"""
|
|
import winreg
|
|
key_path = r"Software\Microsoft\Windows\CurrentVersion\Run"
|
|
app_name = "ProBackupXP"
|
|
|
|
try:
|
|
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, winreg.KEY_ALL_ACCESS)
|
|
if checked:
|
|
# Add to registry
|
|
# Use sys.executable for the running exe path
|
|
exe_path = '"{}"'.format(os.path.abspath(sys.executable))
|
|
winreg.SetValueEx(key, app_name, 0, winreg.REG_SZ, exe_path)
|
|
self.log("Auto Start diaktifkan.")
|
|
else:
|
|
# Remove from registry
|
|
try:
|
|
winreg.DeleteValue(key, app_name)
|
|
self.log("Auto Start dinonaktifkan.")
|
|
except:
|
|
pass # Key might not exist
|
|
winreg.CloseKey(key)
|
|
except Exception as e:
|
|
QMessageBox.critical(self, "Error", "Gagal mengubah pengaturan registry:\n{}".format(e))
|
|
# Revert checkbox state if failed
|
|
self.auto_start_action.setChecked(not checked)
|
|
|
|
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
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
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_())
|
|
|