Compare commits

...

8 Commits

6 changed files with 126 additions and 74 deletions

View File

@@ -100,9 +100,11 @@ exe = EXE(
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
icon='icon.ico',
)

View File

@@ -219,49 +219,60 @@ class BackupStrategy(ABC):
def is_cancelled(self):
return self._cancel_flag
def cleanup_old_backups(self, dest, retention_days):
if retention_days <= 0:
def cleanup_old_backups(self, target_folder, retention_limit):
"""
Count-Based Retention (Scoped):
Keep 'retention_limit' newest items (files/folders) in 'target_folder'.
Do NOT scan recursively. Do NOT look at other date folders.
"""
if retention_limit <= 0:
return
self.log("Menjalankan pembersihan (Retensi: {} hari)...".format(retention_days))
now = time.time()
cutoff = now - (retention_days * 86400)
if not os.path.exists(target_folder):
return
self.log("Menjalankan pembersihan di {} (Batasan: {} item terbaru)...".format(os.path.basename(target_folder), retention_limit))
if not os.path.exists(dest):
return
all_backups = []
count = 0
try:
# Recursive scan for YYYY/MM/DD structure
for root, dirs, files in os.walk(dest):
for filename in files:
filepath = os.path.join(root, filename)
try:
# Check modified time
file_time = os.path.getmtime(filepath)
if file_time < cutoff:
os.remove(filepath)
self.log("Menghapus backup lama: {}".format(filename))
count += 1
except Exception as e:
self.log("Gagal menghapus {}: {}".format(filename, e))
# 1. Collect all items in this SPECIFIC folder only (Non-recursive)
# Python 3.4 compatible (os.scandir came in 3.5)
for entry in os.listdir(target_folder):
try:
full_path = os.path.join(target_folder, entry)
# Get modified time
mtime = os.path.getmtime(full_path)
all_backups.append((full_path, mtime))
except:
pass
# Optional: Remove empty folders after cleanup
for root, dirs, files in os.walk(dest, topdown=False):
for name in dirs:
d_path = os.path.join(root, name)
# 2. Sort by time DESCENDING (Newest first)
all_backups.sort(key=lambda x: x[1], reverse=True)
# 3. Check if we have excess
if len(all_backups) > retention_limit:
items_to_delete = all_backups[retention_limit:]
count = 0
for path, _ in items_to_delete:
try:
if not os.listdir(d_path): # Check if empty
os.rmdir(d_path)
except: pass
if os.path.isdir(path):
shutil.rmtree(path)
else:
os.remove(path)
self.log("Pruning item lama: {}".format(os.path.basename(path)))
count += 1
except Exception as e:
self.log("Gagal menghapus {}: {}".format(os.path.basename(path), e))
if count > 0:
self.log("Pembersihan selesai. Dihapus: {} item.".format(count))
else:
self.log("Jumlah item ({}) aman (Batas: {}).".format(len(all_backups), retention_limit))
except Exception as e:
self.log("Error saat pembersihan: {}".format(e))
if count > 0:
self.log("Pembersihan selesai. Menghapus {} file lama.".format(count))
else:
self.log("Pembersihan selesai. Tidak ada file lama ditemukan.")
def _get_dated_dest_dir(self, base_dest, job_name=None):
"""Creates and returns JobName/YYYY/MM/DD subfolder path inside base_dest."""
@@ -376,14 +387,10 @@ class FileBackup(BackupStrategy):
dst_file = os.path.join(current_dest_dir, file)
shutil.copy2(src_file, dst_file)
processed_files += 1
if total_files > 0:
self.progress(int(processed_files / total_files * 100))
self.log("Penyalinan berhasil diselesaikan.")
# Cleanup
self.cleanup_old_backups(dest, config.get('retention_days', 0))
retention_days = config.get('retention_days', 0)
if retention_days > 0:
self.cleanup_old_backups(target_dest, retention_days)
self.progress(100)
@@ -546,7 +553,7 @@ class MySQLBackup(BackupStrategy):
self.log("Backup MySQL berhasil diselesaikan.")
self.progress(100)
self.cleanup_old_backups(dest, config.get('retention_days', 0))
self.cleanup_old_backups(target_dest, config.get('retention_days', 0))
except Exception as e:
self.log("Gagal melakukan backup: {}".format(e))
@@ -707,7 +714,7 @@ class PostgresBackup(BackupStrategy):
self.log("Backup PostgreSQL berhasil diselesaikan.")
self.progress(100)
self.cleanup_old_backups(dest, config.get('retention_days', 0))
self.cleanup_old_backups(target_dest, config.get('retention_days', 0))
except Exception as e:
self.log("Gagal melakukan backup: {}".format(e))

View File

@@ -3,7 +3,7 @@ import os
import uuid
import base64
CONFIG_FILE = "config.dat" # Obfuscated file
import sys
class ConfigManager:
DEFAULT_JOB = {
@@ -30,6 +30,16 @@ class ConfigManager:
"schedule_unit": "Menit"
}
@staticmethod
def _get_base_dir():
if getattr(sys, 'frozen', False):
return os.path.dirname(sys.executable)
return os.path.dirname(os.path.abspath(__file__))
@staticmethod
def _get_config_path(filename="config.dat"):
return os.path.join(ConfigManager._get_base_dir(), filename)
@staticmethod
def create_new_job():
job = ConfigManager.DEFAULT_JOB.copy()
@@ -38,24 +48,27 @@ class ConfigManager:
@staticmethod
def load_config():
config_dat = ConfigManager._get_config_path("config.dat")
config_json = ConfigManager._get_config_path("config.json")
# Check for old unencrypted config.json and migrate
if os.path.exists("config.json"):
if os.path.exists(config_json):
try:
with open("config.json", 'r') as f:
with open(config_json, 'r') as f:
data = json.load(f)
print("Migrasi config.json...")
config = ConfigManager._migrate_old_config(data)
ConfigManager.save_config(config)
os.rename("config.json", "config.json.bak")
os.rename(config_json, config_json + ".bak")
return config
except Exception as e:
print("Error migrasi config: {}".format(e))
if not os.path.exists(CONFIG_FILE):
if not os.path.exists(config_dat):
return ConfigManager.DEFAULT_CONFIG.copy()
try:
with open(CONFIG_FILE, 'rb') as f:
with open(config_dat, 'rb') as f:
encoded_data = f.read()
# Simple Base64 Decode
@@ -113,7 +126,8 @@ class ConfigManager:
# Simple Base64 Encode
encoded_data = base64.b64encode(json_data.encode('utf-8'))
with open(CONFIG_FILE, 'wb') as f:
config_path = ConfigManager._get_config_path("config.dat")
with open(config_path, 'wb') as f:
f.write(encoded_data)
except Exception as e:
print("Error saving config: {}".format(e))

Binary file not shown.

View File

@@ -4,6 +4,7 @@ import subprocess
import platform
import os
import base64
import sys
# Simple Secret Salt - In a real app, obfuscate this or use asymmetric keys (RSA)
# For this level, a symmetric secret is "good enough" for basic protection.
@@ -14,6 +15,35 @@ CREATE_NO_WINDOW = 0x08000000
class LicenseManager:
@staticmethod
def _get_base_dir():
if getattr(sys, 'frozen', False):
return os.path.dirname(sys.executable)
return os.path.dirname(os.path.abspath(__file__))
@staticmethod
def _get_license_path():
return os.path.join(LicenseManager._get_base_dir(), "license.dat")
@staticmethod
def save_license(key):
try:
with open(LicenseManager._get_license_path(), "w") as f:
f.write(key.strip())
return True
except:
return False
@staticmethod
def load_license():
path = LicenseManager._get_license_path()
if not os.path.exists(path):
return None
try:
with open(path, "r") as f:
return f.read().strip()
except:
return None
@staticmethod
def get_motherboard_id():
"""Gets the motherboard UUID using WMIC (Windows only)."""
try:
@@ -124,21 +154,4 @@ class LicenseManager:
# Neither matches - could be both changed or completely invalid key
return False, 'both_changed'
@staticmethod
def save_license(key):
try:
with open("license.dat", "w") as f:
f.write(key.strip())
return True
except:
return False
@staticmethod
def load_license():
if not os.path.exists("license.dat"):
return None
try:
with open("license.dat", "r") as f:
return f.read().strip()
except:
return None

30
main.py
View File

@@ -8,7 +8,7 @@ from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
QProgressBar, QFileDialog, QMessageBox, QGroupBox,
QSystemTrayIcon, QMenu, QSpinBox, QTabWidget,
QListWidget, QSplitter, QListWidgetItem, QInputDialog, QDialog,
QSizePolicy, QAction, QStyle)
QSizePolicy, QAction, QStyle, QTimeEdit)
from PyQt5.QtCore import Qt, QThread, pyqtSignal as Signal, QTimer, QTime
from PyQt5.QtGui import QIcon
@@ -278,8 +278,11 @@ class BackupApp(QMainWindow):
# Ensure sql_tools folder exists
self.ensure_sql_tools_folder()
self.settings = self.load_settings()
self.trial_limit = 10 # Max uses in trial mode
self.load_settings()
# Job Queue System
self.job_queue = []
if self.trial_mode:
# Check if strict limit reached immediately
@@ -487,7 +490,6 @@ class BackupApp(QMainWindow):
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)
@@ -501,7 +503,8 @@ class BackupApp(QMainWindow):
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.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)
@@ -1308,9 +1311,9 @@ class BackupApp(QMainWindow):
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))
# 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 ---
@@ -1370,6 +1373,13 @@ class BackupApp(QMainWindow):
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)
@@ -1378,6 +1388,12 @@ class BackupApp(QMainWindow):
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):