Files
xp-probackup/main.py

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