import sys import os import ctypes import time from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QComboBox, QStackedWidget, QCheckBox, QTextEdit, QProgressBar, QFileDialog, QMessageBox, QGroupBox, QSystemTrayIcon, QMenu, QSpinBox, QTabWidget, QListWidget, QSplitter, QListWidgetItem, QInputDialog, QDialog, QSizePolicy, QAction, QStyle) from PyQt5.QtCore import Qt, QThread, pyqtSignal as Signal, QTimer, QTime from PyQt5.QtGui import QIcon from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.interval import IntervalTrigger from backup_manager import FileBackup, MySQLBackup, PostgresBackup from config_manager import ConfigManager from license_manager import LicenseManager from registry_manager import RegistryManager from registration_dialog import RegistrationDialog # --- Modern Dark Theme Stylesheet --- STYLESHEET = """ QMainWindow { background-color: #282a36; } QWidget { color: #f8f8f2; font-family: "Segoe UI", sans-serif; font-size: 14px; } QMessageBox { background-color: #f8f8f2; } QMessageBox QLabel { color: #282a36; font-size: 13px; } QMessageBox QPushButton { background-color: #6272a4; color: #f8f8f2; min-width: 80px; padding: 6px 12px; } QGroupBox { border: 1px solid #6272a4; border-radius: 5px; margin-top: 20px; font-weight: bold; } QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 5px; color: #bd93f9; } QLineEdit, QComboBox, QSpinBox, QTimeEdit { background-color: #44475a; border: 1px solid #6272a4; border-radius: 4px; padding: 5px; color: #f8f8f2; } QLineEdit:focus, QComboBox:focus, QSpinBox:focus { border: 1px solid #bd93f9; } QComboBox QAbstractItemView { background-color: #44475a; color: #f8f8f2; selection-background-color: #6272a4; selection-color: #f8f8f2; border: 1px solid #6272a4; } QListWidget { background-color: #44475a; border: 1px solid #6272a4; border-radius: 4px; padding: 5px; outline: none; } QListWidget::item { padding: 10px; border-bottom: 1px solid #6272a4; } QListWidget::item:selected { background-color: #bd93f9; color: #282a36; font-weight: bold; } QPushButton { background-color: #6272a4; color: #f8f8f2; border: none; border-radius: 4px; padding: 8px 16px; font-weight: bold; } QPushButton:hover { background-color: #bd93f9; } QPushButton:pressed { background-color: #ff79c6; } QPushButton#Secondary { background-color: #44475a; } QPushButton#Secondary:hover { background-color: #6272a4; } QProgressBar { border: 1px solid #44475a; border-radius: 4px; text-align: center; background-color: #282a36; } QProgressBar::chunk { background-color: #50fa7b; width: 10px; } QTextEdit { background-color: #282a36; border: 1px solid #44475a; border-radius: 4px; color: #8be9fd; font-family: Consolas, monospace; } QLabel { color: #f8f8f2; } QTabWidget::pane { border: 1px solid #44475a; } QTabBar::tab { background: #44475a; color: #f8f8f2; padding: 10px 25px; margin-left: 2px; min-width: 140px; } QTabBar::tab:selected { background: #6272a4; font-weight: bold; } QSpinBox, QTimeEdit { padding: 5px; border: 1px solid #6272a4; border-radius: 4px; min-height: 25px; /* Compact */ } QSpinBox::up-button, QSpinBox::down-button, QTimeEdit::up-button, QTimeEdit::down-button { /* Hide internal buttons if we use external ones, but for now just reset to default or hide them via python setButtonSymbols */ width: 0px; border: none; background: transparent; } """ class WorkerThread(QThread): progress_signal = Signal(int) log_signal = Signal(str) finished_signal = Signal() error_signal = Signal(str) success_signal = Signal() # New signal for success counting def __init__(self, job_configs): super().__init__() self.job_configs = job_configs # List of jobs self._is_cancelled = False def run(self): total_jobs = len(self.job_configs) for i, job_conf in enumerate(self.job_configs): if self._is_cancelled: self.log_signal.emit(">>> Series Cancelled.") break job_name = job_conf.get('name', 'Unknown Job') self.log_signal.emit("--- Starting Job [{}/{}]: {} ---".format(i+1, total_jobs, job_name)) # Map Config to Strategy strategy = None jtype = job_conf.get('type') # Prepare config dict for strategy # Flatten it a bit for the strategy which expects keys at top level run_config = job_conf.copy() if jtype == 0: # File strategy = FileBackup() run_config['source'] = job_conf.get('source') run_config['archive'] = job_conf.get('zip') run_config['retention_days'] = job_conf.get('retention_days', 0) elif jtype == 1: # MySQL strategy = MySQLBackup() run_config['retention_days'] = job_conf.get('retention_days', 0) run_config['compress'] = job_conf.get('compress', False) elif jtype == 2: # Postgres strategy = PostgresBackup() run_config['retention_days'] = job_conf.get('retention_days', 0) run_config['compress'] = job_conf.get('compress', False) if strategy: strategy.update_signal = self.progress_signal strategy.log_signal = self.log_signal try: strategy.run_backup(run_config) self.success_signal.emit() # Success! except Exception as e: self.log_signal.emit("!!! Error in job {}: {}".format(job_name, str(e))) time.sleep(1) # Breath self.finished_signal.emit() def cancel(self): self._is_cancelled = True class BackupApp(QMainWindow): start_backup_signal = Signal(str) # Now carries job_id def __init__(self, trial_mode=False, instance_socket=None): super().__init__() self.setWindowTitle("ProBackup - Utilitas Backup Multi-Pekerjaan") self.resize(900, 550) # Extremely Compact for 1366x768 screens self.setStyleSheet(STYLESHEET) self.trial_mode = trial_mode self.instance_socket = instance_socket self.trial_timer = None # Set window icon icon_path = os.path.join(os.path.dirname(__file__), "icon.png") if os.path.exists(icon_path): self.setWindowIcon(QIcon(icon_path)) self.worker = None self.scheduler = BackgroundScheduler() self.scheduler.start() self.start_backup_signal.connect(self.scheduled_backup_wrapper) # State self.jobs = [] # List of job dicts self.current_job_id = None # Trial Data self.trial_usage_count = RegistryManager.get_trial_count() self.setup_ui() self.setup_tray() self.setup_single_instance_listener() # Ensure sql_tools folder exists self.ensure_sql_tools_folder() self.load_settings() if self.trial_mode: # Check if strict limit reached immediately if self.trial_usage_count >= 10: QMessageBox.warning(None, "Masa Trial Habis", "Kesempatan pemakaian trial (10x backup) telah habis.\n" "Silakan aktivasi aplikasi.\n\n" "Hubungi WA: +62 817-0380-6655") if not self.open_activation_return_success(): sys.exit(0) else: self.start_trial_timer() def ensure_sql_tools_folder(self): """Create sql_tools folder if it doesn't exist.""" # Determine working directory if getattr(sys, 'frozen', False): base_dir = os.path.dirname(sys.executable) else: base_dir = os.path.dirname(os.path.abspath(__file__)) sql_tools_dir = os.path.join(base_dir, 'sql_tools') if not os.path.exists(sql_tools_dir): try: os.makedirs(sql_tools_dir) # Ensure readme exists readme_path = os.path.join(sql_tools_dir, 'README.txt') with open(readme_path, 'w') as f: f.write("Letakkan file mysqldump.exe, pg_dump.exe, dan pg_dumpall.exe di sini.\n" "Aplikasi akan memprioritaskan tool yang ada di folder ini.") except Exception as e: print("Gagal membuat folder sql_tools: {}".format(e)) def setup_ui(self): central_widget = QWidget() self.setCentralWidget(central_widget) main_layout = QVBoxLayout(central_widget) main_layout.setSpacing(5) # Tighter spacing main_layout.setContentsMargins(5, 5, 5, 5) # Smaller margins # Tabs self.tabs = QTabWidget() # Header as Corner Widget to save vertical space header = QLabel("Utilitas Backup") header.setStyleSheet("font-size: 18px; font-weight: bold; color: #ff79c6; padding-right: 10px;") self.tabs.setCornerWidget(header, Qt.TopRightCorner) main_layout.addWidget(self.tabs) # --- Tab 1: Jobs Management --- jobs_tab = QWidget() jobs_layout = QHBoxLayout(jobs_tab) # Splitter: List vs Details splitter = QSplitter(Qt.Horizontal) # Left: List left_widget = QWidget() left_layout = QVBoxLayout(left_widget) left_layout.setContentsMargins(0,0,0,0) self.job_list = QListWidget() self.job_list.itemClicked.connect(self.on_job_selected) list_btn_layout = QHBoxLayout() add_btn = QPushButton("Baru") add_btn.clicked.connect(self.new_job) copy_btn = QPushButton("Salin") copy_btn.setToolTip("Duplikat pekerjaan yang dipilih") copy_btn.clicked.connect(self.duplicate_job) del_btn = QPushButton("Hapus") del_btn.setObjectName("Secondary") del_btn.clicked.connect(self.delete_job) list_btn_layout.addWidget(add_btn) list_btn_layout.addWidget(copy_btn) list_btn_layout.addWidget(del_btn) left_layout.addWidget(QLabel("Daftar Pekerjaan:")) left_layout.addWidget(self.job_list) left_layout.addLayout(list_btn_layout) # Right: Form self.job_form_widget = QGroupBox("Detail Pekerjaan") self.job_form_widget.setEnabled(False) form_layout = QVBoxLayout(self.job_form_widget) # Combined Name & Type Row job_info_row = QHBoxLayout() job_info_row.setContentsMargins(0, 0, 0, 0) # Name job_info_row.addWidget(QLabel("Pekerjaan:")) self.name_input = QLineEdit() self.name_input.setPlaceholderText("New Backup Job") self.name_input.textChanged.connect(self.auto_save_current_ui) job_info_row.addWidget(self.name_input) # Type job_info_row.addSpacing(10) job_info_row.addWidget(QLabel("Tipe:")) self.type_combo = QComboBox() self.type_combo.addItems(["File/Folder", "Database MySQL", "Database PostgreSQL"]) self.type_combo.setFixedWidth(150) # Compact width self.type_combo.currentIndexChanged.connect(self.on_type_changed) job_info_row.addWidget(self.type_combo) form_layout.addLayout(job_info_row) # Stack # Content Split Layout content_split = QHBoxLayout() # --- LEFT COLUMN (Source) --- source_col = QVBoxLayout() source_col.setContentsMargins(0, 0, 5, 0) # Stack (Contains Source Pages: File / DB) self.stack = QStackedWidget() # Ensure stack pages (which have groups) fill width self.stack.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) self.page_file = self.create_file_page() self.stack.addWidget(self.page_file) self.page_mysql = self.create_db_page("MySQL") self.stack.addWidget(self.page_mysql) self.page_postgres = self.create_db_page("PostgreSQL") self.stack.addWidget(self.page_postgres) source_col.addWidget(self.stack) source_col.addStretch() # Push source content up content_split.addLayout(source_col, stretch=1) # --- RIGHT COLUMN (Target & Schedule) --- target_col = QVBoxLayout() target_col.setContentsMargins(5, 0, 0, 0) # 1. Destination dest_group = QGroupBox("Tujuan Backup") dest_layout = QHBoxLayout() dest_layout.setContentsMargins(5, 5, 5, 5) self.dest_input = QLineEdit() self.dest_input.setPlaceholderText("Lokasi tujuan backup...") self.dest_input.textChanged.connect(self.auto_save_current_ui) browse_dest_btn = QPushButton("Jelajahi") browse_dest_btn.setObjectName("Secondary") browse_dest_btn.clicked.connect(self.browse_dest) open_dest_btn = QPushButton("Buka") open_dest_btn.setObjectName("Secondary") open_dest_btn.setToolTip("Buka folder tujuan backup di File Explorer") open_dest_btn.clicked.connect(self.open_backup_folder) dest_layout.addWidget(self.dest_input) dest_layout.addWidget(browse_dest_btn) dest_layout.addWidget(open_dest_btn) # 1b. Global Compression (Next to Browse) self.global_compress_check = QCheckBox("Kompres") self.global_compress_check.setToolTip("Jika aktif, hasil backup akan dikombinasikan menjadi file .zip") self.global_compress_check.clicked.connect(self.auto_save_current_ui) dest_layout.addWidget(self.global_compress_check) dest_group.setLayout(dest_layout) target_col.addWidget(dest_group) # 2. Schedule sched_group = QGroupBox("Pengaturan Jadwal") sched_layout = QHBoxLayout() sched_layout.setContentsMargins(5, 5, 5, 5) self.job_sched_enable = QCheckBox("Aktifkan Jadwal") self.job_sched_enable.toggled.connect(self.toggle_job_schedule) sched_layout.addWidget(self.job_sched_enable) self.job_sched_type = QComboBox() self.job_sched_type.addItems(["Interval", "Harian"]) self.job_sched_type.setFixedWidth(100) self.job_sched_type.currentIndexChanged.connect(self.on_sched_type_changed) sched_layout.addWidget(self.job_sched_type) self.sched_stack = QStackedWidget() self.sched_stack.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) page_interval = QWidget() pi_layout = QHBoxLayout(page_interval) pi_layout.setContentsMargins(0,0,0,0) pi_layout.addWidget(QLabel("Setiap:")) self.job_interval_spin = QSpinBox() self.job_interval_spin.setRange(1, 9999) self.job_interval_spin.setValue(60) self.job_interval_unit = QComboBox() self.job_interval_unit.addItems(["Menit", "Jam"]) self.create_spinbox_with_buttons(self.job_interval_spin, pi_layout) pi_layout.addWidget(self.job_interval_unit) pi_layout.addStretch() self.sched_stack.addWidget(page_interval) page_daily = QWidget() pd_layout = QHBoxLayout(page_daily) pd_layout.setContentsMargins(0,0,0,0) pd_layout.addWidget(QLabel("Pukul:")) from PyQt5.QtWidgets import QTimeEdit self.job_time_edit = QTimeEdit() self.job_time_edit.setDisplayFormat("HH:mm") self.create_spinbox_with_buttons(self.job_time_edit, pd_layout) pd_layout.addStretch() self.sched_stack.addWidget(page_daily) sched_layout.addWidget(self.sched_stack) sched_layout.addStretch() sched_layout.addWidget(QLabel("| Retensi:")) self.retention_spin = QSpinBox() self.retention_spin.setRange(0, 3650) self.retention_spin.setSpecialValueText("Selamanya") self.retention_spin.setToolTip("Hapus backup lebih lama dari X hari. 0 = Simpan Selamanya.") self.create_spinbox_with_buttons(self.retention_spin, sched_layout) sched_group.setLayout(sched_layout) target_col.addWidget(sched_group) # 3. Global Compression (REMOVED from here) # 4. Action Buttons (Save & Run in one row) action_row = QHBoxLayout() save_job_btn = QPushButton("Simpan") save_job_btn.clicked.connect(self.save_current_job_to_list) self.run_single_btn = QPushButton("Jalankan") self.run_single_btn.setStyleSheet("background-color: #50fa7b; color: #282a36; font-weight: bold;") self.run_single_btn.clicked.connect(self.run_current_job) action_row.addWidget(save_job_btn) action_row.addWidget(self.run_single_btn) target_col.addLayout(action_row) target_col.addStretch() content_split.addLayout(target_col, stretch=1) form_layout.addLayout(content_split) # REMOVED separate run_row form_layout.addStretch() form_layout.addStretch() splitter.addWidget(left_widget) splitter.addWidget(self.job_form_widget) splitter.setStretchFactor(1, 2) jobs_layout.addWidget(splitter) self.tabs.addTab(jobs_tab, "Pekerjaan Backup") # --- Tab 2: Settings (General) --- # settings_tab = QWidget() # ... skipped global schedule replacement ... # --- Bottom Area --- # --- Bottom Area --- bottom_layout = QHBoxLayout() bottom_layout.setContentsMargins(0, 5, 0, 5) # 1. Progress Bar (Left, Expanding - Takes available space) self.progress_bar = QProgressBar() self.progress_bar.setValue(0) self.progress_bar.setFormat("%p%") self.progress_bar.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) self.progress_bar.setStyleSheet("text-align: center; height: 25px;") bottom_layout.addWidget(self.progress_bar, stretch=1) # 2. Buttons (Right, Fit to Text) # Run All Button self.run_all_btn = QPushButton("Jalankan Semua") self.run_all_btn.setMinimumHeight(40) # SizePolicy Preferred means it requests space based on content (text) self.run_all_btn.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) self.run_all_btn.setMinimumWidth(120) # Min width for aesthetics self.run_all_btn.clicked.connect(self.run_all_jobs) bottom_layout.addWidget(self.run_all_btn) # No stretch = does not expand unnecessarily # Cancel Button self.cancel_btn = QPushButton("Batal") self.cancel_btn.setMinimumHeight(40) self.cancel_btn.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) self.cancel_btn.setMinimumWidth(80) # Min width self.cancel_btn.setObjectName("Secondary") self.cancel_btn.setEnabled(False) self.cancel_btn.clicked.connect(self.cancel_backup) bottom_layout.addWidget(self.cancel_btn) main_layout.addLayout(bottom_layout) self.log_area = QTextEdit() self.log_area.setReadOnly(True) self.log_area.setMaximumHeight(150) main_layout.addWidget(self.log_area) # Copyright & License Info hwid = LicenseManager.get_hardware_id() if self.trial_mode: remaining = 10 - self.trial_usage_count remaining = max(0, remaining) status_text = "MODE COBA (Sisa: {})".format(remaining) else: status_text = "Teraktivasi" footer_text = "© Copyright by Wartana 2026 | ID Mesin: {} | Status: {}".format(hwid, status_text) self.copyright_label = QLabel(footer_text) self.copyright_label.setAlignment(Qt.AlignCenter) self.copyright_label.setStyleSheet("color: #6272a4; font-size: 11px;") main_layout.addWidget(self.copyright_label) def start_trial_timer(self): # 30 Minutes Trial self.log(">>> APLIKASI DALAM MODE TRIAL. Anda memiliki 10 kali pemakaian.") self.trial_timer = QTimer(self) self.trial_timer.setSingleShot(True) self.trial_timer.timeout.connect(self.trial_expired) self.trial_timer.start(1800000) # 30 mins in ms def trial_expired(self): QMessageBox.warning(self, "Waktu Trial Habis", "Masa percobaan telah berakhir.\n" "Silakan aktivasi aplikasi.\n\n" "Hubungi WA: +62 817-0380-6655") if not self.open_activation_return_success(): sys.exit(0) def open_activation_dialog(self): # Legacy Wrapper if not self.open_activation_return_success(): sys.exit(0) def open_activation_return_success(self): reg_dialog = RegistrationDialog() if reg_dialog.exec() == QDialog.Accepted: # Success self.trial_mode = False self.update_footer_status() self.log(">>> Aplikasi berhasil diaktivasi!") return True return False def force_activation_dialog(self): """Forces the activation dialog to appear (from tray menu).""" reg_dialog = RegistrationDialog() if reg_dialog.exec() == QDialog.Accepted: self.trial_mode = False self.update_footer_status() self.update_tray_status() self.log(">>> Aplikasi berhasil diaktivasi!") QMessageBox.information(self, "Sukses", "Aktivasi berhasil!") def update_tray_status(self): """Updates tray menu status after activation.""" if hasattr(self, 'status_action'): if self.trial_mode: self.status_action.setText("Status: Mode Trial") else: self.status_action.setText("Status: Teraktivasi ✓") if hasattr(self, 'activate_action'): self.activate_action.setEnabled(self.trial_mode) def update_footer_status(self): hwid = LicenseManager.get_hardware_id() if self.trial_mode: remaining = 10 - self.trial_usage_count remaining = max(0, remaining) status_text = "MODE COBA (Sisa: {})".format(remaining) else: status_text = "Teraktivasi" footer_text = "© Copyright by Wartana 2026 | ID Mesin: {} | Status: {}".format(hwid, status_text) self.copyright_label.setText(footer_text) def check_trial_limit_ok(self): if not self.trial_mode: return True if self.trial_usage_count >= 10: QMessageBox.warning(self, "Batas Trial Tercapai", "Anda telah mencapai batas 10 kali pemakaian backup untuk versi trial.\n" "Silakan aktivasi aplikasi untuk melanjutkan.\n\n" "Hubungi WA: +62 817-0380-6655") if self.open_activation_return_success(): return True return False # Cancelled, but maybe don't exit app inside the check? # The previous logic exited app. Let's keep it consistent or allow viewing? # User said "di tutup, aplikasi dijalankan lagi mau". implies blocking is desired. # But here we are mid-operation. pass # Return False blocks the operation. return True def increment_trial_usage(self): if self.trial_mode: self.trial_usage_count = RegistryManager.increment_trial_count() self.update_footer_status() def create_spinbox_with_buttons(self, widget, layout): widget.setButtonSymbols(QSpinBox.NoButtons) widget.setMinimumHeight(25) # Compact height widget.setStyleSheet("font-size: 13px; font-weight: bold; padding: 0 5px;") minus_btn = QPushButton("-") minus_btn.setFixedSize(25, 25) # Compact button minus_btn.setToolTip("Kurangi") # Fixed visibility: Smaller font, centered minus_btn.setStyleSheet(""" QPushButton { background-color: #e74c3c; color: #ffffff; font-family: Arial, sans-serif; font-size: 18px; font-weight: 900; border-radius: 4px; border: none; padding: 0px; margin: 0px; line-height: normal; } QPushButton:hover { background-color: #c0392b; } QPushButton:pressed { background-color: #a93226; } """) minus_btn.clicked.connect(widget.stepDown) plus_btn = QPushButton("+") plus_btn.setFixedSize(25, 25) # Compact button plus_btn.setToolTip("Tambah") # Fixed visibility plus_btn.setStyleSheet(""" QPushButton { background-color: #2ecc71; color: #ffffff; font-family: Arial, sans-serif; font-size: 18px; font-weight: 900; border-radius: 4px; border: none; padding: 0px; margin: 0px; line-height: normal; } QPushButton:hover { background-color: #27ae60; } QPushButton:pressed { background-color: #219150; } """) plus_btn.clicked.connect(widget.stepUp) # Container to hold them tight container = QWidget() c_layout = QHBoxLayout(container) c_layout.setContentsMargins(0,0,0,0) c_layout.setSpacing(2) c_layout.addWidget(minus_btn) c_layout.addWidget(widget) c_layout.addWidget(plus_btn) layout.addWidget(container) # --- Job Management --- def refresh_job_list(self): self.job_list.clear() for job in self.jobs: item = QListWidgetItem(job['name']) item.setData(Qt.UserRole, job['id']) self.job_list.addItem(item) def new_job(self): job = ConfigManager.create_new_job() # 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? For now let's skip or queue? # Simple approach: Skip if busy. self.log(">>> Jadwal Skip (App Sibuk) untuk Job ID: {}".format(job_id)) return # --- Check Trial Limit --- if not self.check_trial_limit_ok(): self.log(">>> Jadwal Skip (Limit Trial Tercapai) untuk Job ID: {}".format(job_id)) return # Find job job = next((j for j in self.jobs if str(j['id']) == str(job_id)), None) if not job: return self.log(">>> JADWAL OTOMATIS: Menjalankan {}".format(job['name'])) # --- Increment Usage REMOVED from here --- # Run it using the single runner logic mechanism # But we need to be careful about UI state # Since this is "background" but utilizing main thread UI for progress... # We should simulate "Run" button click essentially, but safely. # Reuse Worker logic directly self.run_all_btn.setEnabled(False) self.cancel_btn.setEnabled(True) if hasattr(self, 'run_single_btn'): self.run_single_btn.setEnabled(False) self.job_form_widget.setEnabled(False) self.progress_bar.setValue(0) self.worker = WorkerThread([job]) self.worker.progress_signal.connect(self.update_progress) self.worker.log_signal.connect(self.log) self.worker.finished_signal.connect(self.processing_finished) self.worker.error_signal.connect(self.processing_error) self.worker.success_signal.connect(self.increment_trial_usage) # Count only on success self.worker.start() def cancel_backup(self): if self.worker: self.worker.cancel() self.log(">>> Meminta Pembatalan...") self.cancel_btn.setEnabled(False) # --- Utils --- def log(self, message): self.log_area.append(message) sb = self.log_area.verticalScrollBar() sb.setValue(sb.maximum()) def update_progress(self, val): self.progress_bar.setValue(val) def processing_finished(self): self.run_all_btn.setEnabled(True) self.cancel_btn.setEnabled(False) self.job_form_widget.setEnabled(True) # Unlock if hasattr(self, 'run_single_btn'): self.run_single_btn.setEnabled(True) self.log(">>> Eksekusi Selesai.") def processing_error(self, error_msg): self.run_all_btn.setEnabled(True) self.cancel_btn.setEnabled(False) self.job_form_widget.setEnabled(True) if hasattr(self, 'run_single_btn'): self.run_single_btn.setEnabled(True) self.log("!!! Kesalahan Thread: {}".format(error_msg)) # --- Tray --- def setup_tray(self): self.tray_icon = QSystemTrayIcon(self) # Use custom icon icon_path = os.path.join(os.path.dirname(__file__), "icon.png") if os.path.exists(icon_path): icon = QIcon(icon_path) else: style = self.style() icon = style.standardIcon(QStyle.SP_DriveHDIcon) self.tray_icon.setIcon(icon) tray_menu = QMenu() # Status label if self.trial_mode: status_text = "Status: Mode Trial" else: status_text = "Status: Teraktivasi ✓" self.status_action = QAction(status_text, self) self.status_action.setEnabled(False) # Read-only label show_action = QAction("Tampilkan", self) show_action.triggered.connect(self.show_window) self.activate_action = QAction("Aktivasi...", self) self.activate_action.triggered.connect(self.force_activation_dialog) # Disable if already activated if not self.trial_mode: self.activate_action.setEnabled(False) # 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_())