feat: Initial commit for OnEver Drive centralized backup system with Windows PyQt6 agent and Proxmox LXC deployment

This commit is contained in:
2026-08-13 19:33:44 -03:00
commit 1bfb808c79
77 changed files with 10675 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
import math
import hashlib
from pathlib import Path
from typing import Tuple
def compute_file_sha256(filepath: Path, buffer_size: int = 1024 * 1024) -> str:
"""Calculates full SHA-256 checksum of a file using streaming buffers to avoid memory overhead."""
hasher = hashlib.sha256()
with open(filepath, "rb") as f:
while True:
chunk = f.read(buffer_size)
if not chunk:
break
hasher.update(chunk)
return hasher.hexdigest()
class FileChunker:
"""Handles splitting large files into discrete chunks and calculating block hashes."""
def __init__(self, filepath: Path, chunk_size: int = 4 * 1024 * 1024):
self.filepath = Path(filepath).resolve()
if not self.filepath.exists():
raise FileNotFoundError(f"File {filepath} not found")
self.file_size = self.filepath.stat().st_size
self.chunk_size = chunk_size
self.total_chunks = max(1, math.ceil(self.file_size / self.chunk_size))
self._cached_sha256 = None
@property
def full_sha256(self) -> str:
if self._cached_sha256 is None:
self._cached_sha256 = compute_file_sha256(self.filepath)
return self._cached_sha256
def get_chunk(self, chunk_index: int) -> Tuple[bytes, str]:
"""
Reads the bytes for chunk `chunk_index` and returns (chunk_data, chunk_sha256).
"""
if chunk_index < 0 or chunk_index >= self.total_chunks:
raise IndexError(f"Chunk index {chunk_index} out of bounds (total: {self.total_chunks})")
offset = chunk_index * self.chunk_size
with open(self.filepath, "rb") as f:
f.seek(offset)
data = f.read(self.chunk_size)
chunk_sha256 = hashlib.sha256(data).hexdigest()
return data, chunk_sha256
+62
View File
@@ -0,0 +1,62 @@
import os
import json
import uuid
from pathlib import Path
from typing import Optional, List
from pydantic import BaseModel, Field
AGENT_HOME = Path(os.environ.get("PROGRAMDATA", "C:/ProgramData")) / "OnEverDrive"
if not AGENT_HOME.exists():
try:
AGENT_HOME.mkdir(parents=True, exist_ok=True)
except Exception:
AGENT_HOME = Path(__file__).resolve().parent.parent / "data"
AGENT_HOME.mkdir(parents=True, exist_ok=True)
CONFIG_FILE = AGENT_HOME / "config.json"
class LocalFolderJob(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4())[:8])
name: str
source_path: str
file_patterns: str = "*.bak,*.mdf"
schedule_interval_minutes: int = 60
min_stable_seconds: int = 60
is_active: bool = True
last_backup_at: Optional[str] = None
last_status: Optional[str] = "En espera"
class AgentConfig(BaseModel):
server_url: str = "http://127.0.0.1:8000"
client_code: Optional[str] = None
device_id: Optional[str] = None
device_token: Optional[str] = None
client_name: Optional[str] = None
chunk_size: int = 4 * 1024 * 1024 # 4 MB
heartbeat_interval_seconds: int = 30
min_stable_time_seconds: int = 60
log_level: str = "INFO"
local_folders: List[LocalFolderJob] = []
# Notification preferences (configurable from GUI and Tray)
enable_notifications: bool = True
notify_on_start: bool = True
notify_on_complete: bool = True
notify_on_error: bool = True
def load_config() -> AgentConfig:
"""Loads agent configuration from disk or returns default configuration."""
if CONFIG_FILE.exists():
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
return AgentConfig(**data)
except Exception:
pass
return AgentConfig()
def save_config(config: AgentConfig) -> None:
"""Persists agent configuration to disk."""
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(config.model_dump(), f, indent=2)
+82
View File
@@ -0,0 +1,82 @@
import os
import time
import fnmatch
from pathlib import Path
from typing import List, Tuple, Optional
def is_file_locked(filepath: Path) -> bool:
"""
Checks if a file is locked exclusively by another process (e.g. SQL Server writing .bak).
Attempts to open the file with read-shared permissions.
"""
if not filepath.exists() or not filepath.is_file():
return True
try:
# On Windows, try opening in append/read mode to detect exclusive write lock
with open(filepath, "rb") as f:
f.seek(0, os.SEEK_END)
return False
except (PermissionError, IOError, OSError):
return True
def is_file_stable(filepath: Path, min_stable_seconds: int = 60, sample_interval_seconds: float = 0.5) -> bool:
"""
Ensures that a file is not actively growing or being modified.
Verifies that modification timestamp and size are stable.
"""
if is_file_locked(filepath):
return False
try:
stat_initial = filepath.stat()
initial_size = stat_initial.st_size
initial_mtime = stat_initial.st_mtime
# Check if the file was modified very recently compared to current time
current_time = time.time()
if (current_time - initial_mtime) < min_stable_seconds:
# File was modified less than min_stable_seconds ago; perform sample check
time.sleep(sample_interval_seconds)
stat_second = filepath.stat()
if stat_second.st_size != initial_size or stat_second.st_mtime != initial_mtime:
return False
return True
except Exception:
return False
class DirectoryScanner:
"""Scans Windows source paths for files matching specific backup patterns."""
def __init__(self, source_path: str, file_patterns: str = "*.bak,*.mdf", min_stable_seconds: int = 60):
self.source_path = Path(source_path).resolve()
self.patterns = [p.strip() for p in file_patterns.split(",") if p.strip()]
self.min_stable_seconds = min_stable_seconds
def scan(self) -> List[Path]:
"""Returns list of all matching files that are stable and ready for backup."""
if not self.source_path.exists():
return []
matched_files: List[Path] = []
if self.source_path.is_file():
if self._matches_patterns(self.source_path.name):
matched_files.append(self.source_path)
return matched_files
for root, _, files in os.walk(self.source_path):
for file in files:
if self._matches_patterns(file):
full_path = Path(root) / file
matched_files.append(full_path)
return matched_files
def _matches_patterns(self, filename: str) -> bool:
if not self.patterns or "*" in self.patterns:
return True
for pattern in self.patterns:
if fnmatch.fnmatch(filename.lower(), pattern.lower()):
return True
return False
+193
View File
@@ -0,0 +1,193 @@
import time
import socket
import platform
import threading
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional, Callable, Dict, Any, List
import httpx
from agent.config import AgentConfig, load_config, save_config, LocalFolderJob
from agent.scanner import DirectoryScanner, is_file_stable
from agent.chunker import compute_file_sha256
from agent.uploader import ChunkUploader
from agent.state_db import state_db
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] [OnEver Agent] %(message)s"
)
logger = logging.getLogger("OnEverAgent")
class AgentDaemon:
"""Background service worker for Windows: handles heartbeats, job polling and scheduled backups."""
def __init__(
self,
config: Optional[AgentConfig] = None,
on_started: Optional[Callable[[str, int], None]] = None,
on_progress: Optional[Callable[[str, int, int, float], None]] = None,
on_completed: Optional[Callable[[str, str, int], None]] = None,
on_error: Optional[Callable[[str, str], None]] = None,
on_status: Optional[Callable[[str, str], None]] = None
):
self.config = config or load_config()
self.running = False
self.uploader = ChunkUploader(self.config)
self._heartbeat_thread: Optional[threading.Thread] = None
self._worker_thread: Optional[threading.Thread] = None
# Event callbacks
self.on_started = on_started
self.on_progress = on_progress
self.on_completed = on_completed
self.on_error = on_error
self.on_status = on_status
def start(self):
self.config = load_config()
if not self.config.device_id or not self.config.device_token:
logger.warning("Agent is not registered yet. Waiting for registration.")
if self.on_status:
self.on_status("UNREGISTERED", "El agente no está registrado en el servidor.")
return
self.running = True
logger.info(f"Starting OnEver Drive Windows Agent ({self.config.client_code} - {self.config.client_name})")
logger.info(f"Target Server: {self.config.server_url}")
if self.on_status:
self.on_status("ONLINE", f"Conectado a {self.config.server_url} ({self.config.client_code})")
self._heartbeat_thread = threading.Thread(target=self._heartbeat_loop, daemon=True)
self._worker_thread = threading.Thread(target=self._backup_worker_loop, daemon=True)
self._heartbeat_thread.start()
self._worker_thread.start()
def stop(self):
logger.info("Stopping agent daemon...")
self.running = False
if self.on_status:
self.on_status("PAUSED", "Servicio en pausa.")
def _get_headers(self):
return {
"X-Device-Id": self.config.device_id,
"X-Device-Token": self.config.device_token
}
def _heartbeat_loop(self):
while self.running:
try:
base_url = self.config.server_url.rstrip("/")
with httpx.Client(base_url=base_url, headers=self._get_headers(), timeout=10.0) as client:
resp = client.get("/api/jobs/agent/assigned")
if resp.status_code == 200:
logger.debug("Heartbeat acknowledged by server.")
except Exception as ex:
logger.warning(f"Heartbeat failed: {str(ex)}")
time.sleep(self.config.heartbeat_interval_seconds)
def _backup_worker_loop(self):
while self.running:
try:
self._run_backup_cycle()
except Exception as ex:
logger.error(f"Error during backup cycle: {str(ex)}")
time.sleep(30)
def _run_backup_cycle(self):
self.config = load_config()
self.uploader.config = self.config
# 1. Fetch server-assigned jobs
server_jobs = []
try:
base_url = self.config.server_url.rstrip("/")
with httpx.Client(base_url=base_url, headers=self._get_headers(), timeout=15.0) as client:
resp = client.get("/api/jobs/agent/assigned")
if resp.status_code == 200:
server_jobs = resp.json()
except Exception:
pass
# 2. Combine server jobs + user local folders
all_jobs = []
for sj in server_jobs:
all_jobs.append({
"job_id": sj.get("id"),
"name": sj.get("name"),
"source_path": sj.get("source_path"),
"file_patterns": sj.get("file_patterns", "*.*"),
"min_stable_seconds": sj.get("min_stable_time_seconds", 60)
})
for lj in self.config.local_folders:
if lj.is_active:
all_jobs.append({
"job_id": None,
"local_job_id": lj.id,
"name": lj.name,
"source_path": lj.source_path,
"file_patterns": lj.file_patterns,
"min_stable_seconds": lj.min_stable_seconds
})
# 3. Process jobs
for job in all_jobs:
source_path = job["source_path"]
file_patterns = job["file_patterns"]
min_stable = job["min_stable_seconds"]
job_name = job["name"]
scanner = DirectoryScanner(source_path, file_patterns, min_stable_seconds=min_stable)
files = scanner.scan()
for filepath in files:
if not is_file_stable(filepath, min_stable_seconds=min_stable):
logger.warning(f"File {filepath.name} is currently locked or growing. Skipping.")
continue
current_sha = compute_file_sha256(filepath)
if state_db.is_file_already_backed_up(str(filepath), current_sha):
continue
file_size_bytes = filepath.stat().st_size
logger.info(f"Starting backup for file: {filepath.name} ({file_size_bytes / (1024*1024):.2f} MB)")
# Single notification when the process starts
if self.on_started:
self.on_started(filepath.name, file_size_bytes)
def on_chunk_progress(done, total, pct):
if self.on_progress:
self.on_progress(filepath.name, done, total, pct)
try:
res = self.uploader.upload_file(
filepath,
job_id=job.get("job_id"),
progress_callback=on_chunk_progress
)
logger.info(f"Successfully backed up {filepath.name}!")
# Update local folder last backup status
if "local_job_id" in job:
for folder in self.config.local_folders:
if folder.id == job["local_job_id"]:
folder.last_backup_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
folder.last_status = "Backup Exitoso"
save_config(self.config)
# Single notification when the process finishes
if self.on_completed:
self.on_completed(filepath.name, res.get("sha256", ""), file_size_bytes)
except Exception as ex:
logger.error(f"Failed to backup {filepath.name}: {str(ex)}")
if self.on_error:
self.on_error(filepath.name, str(ex))
+102
View File
@@ -0,0 +1,102 @@
import sqlite3
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional, Dict, Any, List
from agent.config import AGENT_HOME
STATE_DB_PATH = AGENT_HOME / "agent_state.db"
class StateDatabase:
"""Local SQLite database for agent offline resiliency, session resume tracking and file caching."""
def __init__(self, db_path: Path = STATE_DB_PATH):
self.db_path = db_path
self._init_db()
def _get_conn(self) -> sqlite3.Connection:
return sqlite3.connect(str(self.db_path))
def _init_db(self):
with self._get_conn() as conn:
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS upload_sessions (
filepath TEXT PRIMARY KEY,
sha256 TEXT NOT NULL,
session_code TEXT NOT NULL,
total_chunks INTEGER NOT NULL,
chunk_size INTEGER NOT NULL,
completed_chunks INTEGER DEFAULT 0,
status TEXT NOT NULL,
last_updated TIMESTAMP NOT NULL
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS completed_files (
filepath TEXT PRIMARY KEY,
sha256 TEXT NOT NULL,
file_size INTEGER NOT NULL,
job_id INTEGER,
last_backup_time TIMESTAMP NOT NULL
)
""")
conn.commit()
def save_session(
self,
filepath: str,
sha256: str,
session_code: str,
total_chunks: int,
chunk_size: int,
status: str = "UPLOADING"
):
with self._get_conn() as conn:
cursor = conn.cursor()
now = datetime.now(timezone.utc).isoformat()
cursor.execute("""
INSERT OR REPLACE INTO upload_sessions
(filepath, sha256, session_code, total_chunks, chunk_size, status, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (filepath, sha256, session_code, total_chunks, chunk_size, status, now))
conn.commit()
def get_session(self, filepath: str) -> Optional[Dict[str, Any]]:
with self._get_conn() as conn:
cursor = conn.cursor()
cursor.execute("SELECT filepath, sha256, session_code, total_chunks, chunk_size, completed_chunks, status FROM upload_sessions WHERE filepath = ?", (filepath,))
row = cursor.fetchone()
if row:
return {
"filepath": row[0],
"sha256": row[1],
"session_code": row[2],
"total_chunks": row[3],
"chunk_size": row[4],
"completed_chunks": row[5],
"status": row[6]
}
return None
def mark_session_completed(self, filepath: str, sha256: str, file_size: int, job_id: Optional[int] = None):
with self._get_conn() as conn:
cursor = conn.cursor()
now = datetime.now(timezone.utc).isoformat()
cursor.execute("DELETE FROM upload_sessions WHERE filepath = ?", (filepath,))
cursor.execute("""
INSERT OR REPLACE INTO completed_files (filepath, sha256, file_size, job_id, last_backup_time)
VALUES (?, ?, ?, ?, ?)
""", (filepath, sha256, file_size, job_id, now))
conn.commit()
def is_file_already_backed_up(self, filepath: str, current_sha256: str) -> bool:
with self._get_conn() as conn:
cursor = conn.cursor()
cursor.execute("SELECT sha256 FROM completed_files WHERE filepath = ?", (filepath,))
row = cursor.fetchone()
if row and row[0] == current_sha256:
return True
return False
state_db = StateDatabase()
+117
View File
@@ -0,0 +1,117 @@
import time
from pathlib import Path
from typing import Optional, Callable, Dict, Any, List
import httpx
from agent.config import AgentConfig, load_config
from agent.chunker import FileChunker
from agent.state_db import state_db
class ChunkUploader:
"""HTTP/HTTPS Chunk transfer client with automatic resumption and retry backoff."""
def __init__(self, config: Optional[AgentConfig] = None):
self.config = config or load_config()
def _get_headers(self) -> Dict[str, str]:
if not self.config.device_id or not self.config.device_token:
raise ValueError("Agent is not registered. Run 'agent_cli.py register' first.")
return {
"X-Device-Id": self.config.device_id,
"X-Device-Token": self.config.device_token
}
def upload_file(
self,
filepath: Path,
job_id: Optional[int] = None,
progress_callback: Optional[Callable[[int, int, float], None]] = None,
max_retries: int = 5
) -> Dict[str, Any]:
"""
Uploads a file chunk by chunk. If interrupted, querying the server will return
already received chunks, allowing immediate resumption without re-uploading completed parts.
"""
filepath = Path(filepath).resolve()
chunker = FileChunker(filepath=filepath, chunk_size=self.config.chunk_size)
full_sha256 = chunker.full_sha256
filename = filepath.name
headers = self._get_headers()
base_url = self.config.server_url.rstrip("/")
with httpx.Client(base_url=base_url, headers=headers, timeout=60.0) as client:
# 1. Initialize or resume upload session
init_payload = {
"filename": filename,
"file_size": chunker.file_size,
"sha256": full_sha256,
"chunk_size": chunker.chunk_size,
"job_id": job_id
}
resp = client.post("/api/upload/session", json=init_payload)
resp.raise_for_status()
session_data = resp.json()
session_code = session_data["session_code"]
total_chunks = session_data["total_chunks"]
received_chunks = set(session_data.get("received_chunks", []))
state_db.save_session(
filepath=str(filepath),
sha256=full_sha256,
session_code=session_code,
total_chunks=total_chunks,
chunk_size=chunker.chunk_size,
status="UPLOADING"
)
# 2. Upload missing chunks
missing_chunks = [i for i in range(total_chunks) if i not in received_chunks]
for idx in missing_chunks:
chunk_bytes, chunk_hash = chunker.get_chunk(idx)
# Retry loop with exponential backoff for network resilience
success = False
attempt = 0
while not success and attempt < max_retries:
try:
chunk_headers = {
"Content-Type": "application/octet-stream",
"X-Chunk-Index": str(idx),
"X-Chunk-SHA256": chunk_hash
}
c_resp = client.post(
f"/api/upload/{session_code}/chunk?chunk_index={idx}&chunk_sha256={chunk_hash}",
content=chunk_bytes,
headers=chunk_headers
)
c_resp.raise_for_status()
success = True
except Exception as ex:
attempt += 1
if attempt >= max_retries:
raise ConnectionError(f"Failed to upload chunk {idx} after {max_retries} attempts: {str(ex)}")
time.sleep(2 ** attempt)
received_chunks.add(idx)
if progress_callback:
pct = round((len(received_chunks) / total_chunks) * 100, 2)
progress_callback(len(received_chunks), total_chunks, pct)
# 3. Complete and verify full file assembly on server
complete_resp = client.post(f"/api/upload/{session_code}/complete")
complete_resp.raise_for_status()
result = complete_resp.json()
# 4. Update local state database
state_db.mark_session_completed(
filepath=str(filepath),
sha256=full_sha256,
file_size=chunker.file_size,
job_id=job_id
)
return result
+817
View File
@@ -0,0 +1,817 @@
import os
import sys
import time
import socket
import platform
import threading
from pathlib import Path
from typing import Optional, List
import httpx
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QSize
from PyQt6.QtGui import QIcon, QFont, QColor, QAction
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QTabWidget, QLabel, QPushButton, QLineEdit, QTableWidget,
QTableWidgetItem, QHeaderView, QProgressBar, QFileDialog,
QMessageBox, QSystemTrayIcon, QMenu, QDialog, QFormLayout,
QComboBox, QSpinBox, QFrame, QCheckBox
)
# Add agent root to sys.path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from agent.config import load_config, save_config, AgentConfig, LocalFolderJob
from agent.service import AgentDaemon
from agent.uploader import ChunkUploader
from agent.chunker import compute_file_sha256
from agent.state_db import state_db
from create_icons import generate_app_icons
# --- Modern Dark QSS Stylesheet ---
DARK_QSS = """
QMainWindow, QWidget {
background-color: #0B0F19;
color: #F8FAFC;
font-family: 'Segoe UI', Arial, sans-serif;
font-size: 13px;
}
QTabWidget::pane {
border: 1px solid rgba(255, 255, 255, 0.08);
background-color: #111827;
border-radius: 8px;
}
QTabBar::tab {
background: #1E293B;
color: #94A3B8;
padding: 10px 20px;
margin-right: 4px;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
font-weight: bold;
}
QTabBar::tab:selected {
background: #06B6D4;
color: #FFFFFF;
}
QTabBar::tab:hover:!selected {
background: #334155;
color: #FFFFFF;
}
QFrame.card {
background-color: #1E293B;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
padding: 16px;
}
QLineEdit, QComboBox, QSpinBox {
background-color: #0B0F19;
border: 1px solid #334155;
border-radius: 6px;
color: #FFFFFF;
padding: 8px 12px;
font-size: 13px;
}
QLineEdit:focus, QComboBox:focus, QSpinBox:focus {
border: 1px solid #06B6D4;
}
QCheckBox {
color: #E2E8F0;
font-size: 13px;
spacing: 8px;
}
QCheckBox::indicator {
width: 18px;
height: 18px;
border-radius: 4px;
border: 1px solid #475569;
background-color: #0B0F19;
}
QCheckBox::indicator:checked {
background-color: #06B6D4;
border-color: #06B6D4;
}
QPushButton {
background-color: #334155;
color: #FFFFFF;
border: none;
border-radius: 6px;
padding: 9px 18px;
font-weight: bold;
}
QPushButton:hover {
background-color: #475569;
}
QPushButton.primary {
background-color: #06B6D4;
color: #FFFFFF;
}
QPushButton.primary:hover {
background-color: #0891B2;
}
QPushButton.success {
background-color: #10B981;
color: #FFFFFF;
}
QPushButton.success:hover {
background-color: #059669;
}
QPushButton.danger {
background-color: rgba(244, 63, 94, 0.2);
color: #FDA4AF;
border: 1px solid rgba(244, 63, 94, 0.4);
}
QPushButton.danger:hover {
background-color: rgba(244, 63, 94, 0.35);
}
QProgressBar {
background-color: #1E293B;
border: 1px solid #334155;
border-radius: 6px;
text-align: center;
color: #FFFFFF;
font-weight: bold;
height: 18px;
}
QProgressBar::chunk {
background-color: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #06B6D4, stop:1 #6366F1);
border-radius: 5px;
}
QTableWidget {
background-color: #0B0F19;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 6px;
gridline-color: rgba(255, 255, 255, 0.04);
}
QTableWidget::item {
padding: 8px;
color: #F8FAFC;
}
QTableWidget::item:selected {
background-color: rgba(6, 182, 212, 0.2);
}
QHeaderView::section {
background-color: #1E293B;
color: #94A3B8;
padding: 8px;
font-weight: bold;
border: none;
border-bottom: 1px solid #334155;
}
QMenu {
background-color: #1E293B;
color: #FFFFFF;
border: 1px solid #334155;
}
QMenu::item:selected {
background-color: #06B6D4;
}
"""
class WorkerSignals(QThread):
started_signal = pyqtSignal(str, int)
progress_signal = pyqtSignal(str, int, int, float)
completed_signal = pyqtSignal(str, str, int)
error_signal = pyqtSignal(str, str)
status_signal = pyqtSignal(str, str)
class AddFolderDialog(QDialog):
"""Dialog for selecting and configuring a Windows backup folder."""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Añadir Carpeta de Backup — OnEver Drive")
self.resize(500, 320)
self.setStyleSheet(DARK_QSS)
layout = QVBoxLayout(self)
layout.setSpacing(14)
form = QFormLayout()
form.setSpacing(12)
self.txt_name = QLineEdit()
self.txt_name.setPlaceholderText("Ej: Base de Datos SQL Producción")
form.addRow("Nombre descriptivo:", self.txt_name)
path_layout = QHBoxLayout()
self.txt_path = QLineEdit()
self.txt_path.setPlaceholderText("C:\\SQLBackups")
btn_browse = QPushButton("Explorar...")
btn_browse.clicked.connect(self._browse_folder)
path_layout.addWidget(self.txt_path)
path_layout.addWidget(btn_browse)
form.addRow("Ruta en Windows:", path_layout)
self.txt_patterns = QLineEdit()
self.txt_patterns.setText("*.bak,*.mdf")
form.addRow("Filtros de archivo:", self.txt_patterns)
self.spin_interval = QSpinBox()
self.spin_interval.setRange(5, 1440)
self.spin_interval.setValue(60)
self.spin_interval.setSuffix(" min")
form.addRow("Frecuencia de sondeo:", self.spin_interval)
self.spin_stable = QSpinBox()
self.spin_stable.setRange(10, 600)
self.spin_stable.setValue(60)
self.spin_stable.setSuffix(" seg")
form.addRow("Estabilidad de archivo (Locks):", self.spin_stable)
layout.addLayout(form)
btn_layout = QHBoxLayout()
btn_layout.addStretch()
btn_cancel = QPushButton("Cancelar")
btn_cancel.clicked.connect(self.reject)
btn_save = QPushButton("Guardar Carpeta")
btn_save.setProperty("class", "primary")
btn_save.clicked.connect(self._validate_and_accept)
btn_layout.addWidget(btn_cancel)
btn_layout.addWidget(btn_save)
layout.addLayout(btn_layout)
def _browse_folder(self):
folder = QFileDialog.getExistingDirectory(self, "Seleccionar carpeta para backup")
if folder:
self.txt_path.setText(folder)
if not self.txt_name.text():
self.txt_name.setText(Path(folder).name)
def _validate_and_accept(self):
if not self.txt_path.text() or not os.path.exists(self.txt_path.text()):
QMessageBox.warning(self, "Ruta Inválida", "Por favor selecciona una carpeta existente en Windows.")
return
if not self.txt_name.text():
self.txt_name.setText(Path(self.txt_path.text()).name)
self.accept()
def get_data(self) -> LocalFolderJob:
return LocalFolderJob(
name=self.txt_name.text().strip(),
source_path=self.txt_path.text().strip(),
file_patterns=self.txt_patterns.text().strip() or "*.*",
schedule_interval_minutes=self.spin_interval.value(),
min_stable_seconds=self.spin_stable.value()
)
class OnEverDriveMainWindow(QMainWindow):
"""Main modern desktop GUI and tray controller for OnEver Drive Windows Agent."""
def __init__(self):
super().__init__()
self.setWindowTitle("OnEver Drive — Agente de Backup Windows")
self.resize(780, 580)
self.setStyleSheet(DARK_QSS)
self.config = load_config()
self.app_icon = self._load_app_icon()
self.setWindowIcon(self.app_icon)
# Background Worker Daemon
self.signals = WorkerSignals()
self.daemon = AgentDaemon(
config=self.config,
on_started=lambda f, b: self.signals.started_signal.emit(f, b),
on_progress=lambda f, d, t, p: self.signals.progress_signal.emit(f, d, t, p),
on_completed=lambda f, s, b: self.signals.completed_signal.emit(f, s, b),
on_error=lambda f, e: self.signals.error_signal.emit(f, e),
on_status=lambda s, m: self.signals.status_signal.emit(s, m)
)
self._connect_signals()
self._init_ui()
self._init_tray()
if self.config.device_id:
self.daemon.start()
def _load_app_icon(self) -> QIcon:
assets_dir = Path(__file__).resolve().parent / "assets"
png_path = assets_dir / "icon.png"
if not png_path.exists():
_, png_path = generate_app_icons()
return QIcon(str(png_path))
def _connect_signals(self):
self.signals.started_signal.connect(self._on_backup_started)
self.signals.progress_signal.connect(self._on_live_progress)
self.signals.completed_signal.connect(self._on_backup_completed)
self.signals.error_signal.connect(self._on_backup_error)
self.signals.status_signal.connect(self._on_daemon_status)
def _init_ui(self):
central = QWidget()
self.setCentralWidget(central)
main_layout = QVBoxLayout(central)
main_layout.setContentsMargins(20, 20, 20, 20)
main_layout.setSpacing(16)
# Header Bar
header = QHBoxLayout()
title_box = QVBoxLayout()
lbl_title = QLabel("OnEver Drive")
lbl_title.setFont(QFont("Segoe UI", 16, QFont.Weight.Bold))
lbl_sub = QLabel("Agente Empresarial de Sincronización y Backup para Windows")
lbl_sub.setStyleSheet("color: #94A3B8; font-size: 11px;")
title_box.addWidget(lbl_title)
title_box.addWidget(lbl_sub)
header.addLayout(title_box)
header.addStretch()
self.lbl_status_badge = QLabel("● Desconectado")
self.lbl_status_badge.setStyleSheet("background-color: #7F1D1D; color: #FCA5A5; font-weight: bold; border-radius: 4px; padding: 6px 12px;")
header.addWidget(self.lbl_status_badge)
main_layout.addLayout(header)
# Tabs
self.tabs = QTabWidget()
self.tab_dashboard = QWidget()
self.tab_folders = QWidget()
self.tab_config = QWidget()
self.tab_history = QWidget()
self.tabs.addTab(self.tab_dashboard, "Dashboard")
self.tabs.addTab(self.tab_folders, "Carpetas de Backup")
self.tabs.addTab(self.tab_config, "Servidor & Config")
self.tabs.addTab(self.tab_history, "Historial de Archivos")
main_layout.addWidget(self.tabs)
self._build_tab_dashboard()
self._build_tab_folders()
self._build_tab_config()
self._build_tab_history()
self._update_header_status()
# --- TAB 1: DASHBOARD ---
def _build_tab_dashboard(self):
layout = QVBoxLayout(self.tab_dashboard)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(16)
card_tel = QFrame()
card_tel.setProperty("class", "card")
tel_layout = QVBoxLayout(card_tel)
lbl_sec = QLabel("Transferencia en Vivo (Motor de Chunks)")
lbl_sec.setFont(QFont("Segoe UI", 12, QFont.Weight.Bold))
tel_layout.addWidget(lbl_sec)
self.lbl_transfer_info = QLabel("Estado: En espera de cambios en carpetas monitoreadas...")
self.lbl_transfer_info.setStyleSheet("color: #94A3B8;")
tel_layout.addWidget(self.lbl_transfer_info)
self.pbar_transfer = QProgressBar()
self.pbar_transfer.setValue(0)
tel_layout.addWidget(self.pbar_transfer)
self.lbl_chunk_details = QLabel("Chunks: 0 / 0 | Motor por Bloques de 4MB | SHA-256: —")
self.lbl_chunk_details.setStyleSheet("color: #64748B; font-family: 'Consolas', monospace; font-size: 11px;")
tel_layout.addWidget(self.lbl_chunk_details)
layout.addWidget(card_tel)
card_info = QFrame()
card_info.setProperty("class", "card")
info_layout = QVBoxLayout(card_info)
lbl_info_title = QLabel("Información del Dispositivo")
lbl_info_title.setFont(QFont("Segoe UI", 11, QFont.Weight.Bold))
info_layout.addWidget(lbl_info_title)
self.lbl_dash_client = QLabel("Cliente ID: —")
self.lbl_dash_server = QLabel("Servidor Proxmox: —")
self.lbl_dash_folders = QLabel("Carpetas en Monitoreo: 0")
info_layout.addWidget(self.lbl_dash_client)
info_layout.addWidget(self.lbl_dash_server)
info_layout.addWidget(self.lbl_dash_folders)
layout.addWidget(card_info)
btn_box = QHBoxLayout()
btn_backup_all = QPushButton("▶ Iniciar Sincronización Manual Ahora")
btn_backup_all.setProperty("class", "primary")
btn_backup_all.clicked.connect(self._trigger_all_backups)
btn_box.addWidget(btn_backup_all)
layout.addLayout(btn_box)
layout.addStretch()
# --- TAB 2: CARPETAS DE BACKUP ---
def _build_tab_folders(self):
layout = QVBoxLayout(self.tab_folders)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(12)
top_bar = QHBoxLayout()
lbl = QLabel("Carpetas de Windows Monitoreadas")
lbl.setFont(QFont("Segoe UI", 12, QFont.Weight.Bold))
top_bar.addWidget(lbl)
top_bar.addStretch()
btn_add = QPushButton(" Añadir Carpeta...")
btn_add.setProperty("class", "primary")
btn_add.clicked.connect(self._show_add_folder_dialog)
top_bar.addWidget(btn_add)
layout.addLayout(top_bar)
self.tbl_folders = QTableWidget(0, 5)
self.tbl_folders.setHorizontalHeaderLabels(["Nombre", "Ruta en Windows", "Filtros", "Intervalo", "Último Estado"])
self.tbl_folders.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
self.tbl_folders.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
layout.addWidget(self.tbl_folders)
btn_row = QHBoxLayout()
btn_delete = QPushButton("🗑️ Eliminar Carpeta")
btn_delete.setProperty("class", "danger")
btn_delete.clicked.connect(self._delete_selected_folder)
btn_row.addWidget(btn_delete)
btn_row.addStretch()
layout.addLayout(btn_row)
self._refresh_folders_table()
# --- TAB 3: CONFIGURACIÓN & NOTIFICACIONES ---
def _build_tab_config(self):
layout = QVBoxLayout(self.tab_config)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(16)
# Server Card
card_srv = QFrame()
card_srv.setProperty("class", "card")
form = QFormLayout(card_srv)
form.setSpacing(12)
lbl = QLabel("Conexión con el Servidor Central Proxmox VE")
lbl.setFont(QFont("Segoe UI", 12, QFont.Weight.Bold))
form.addRow(lbl)
self.txt_server_url = QLineEdit()
self.txt_server_url.setText(self.config.server_url)
btn_ping = QPushButton("🔍 Probar Conexión")
btn_ping.clicked.connect(self._test_server_connection)
srv_box = QHBoxLayout()
srv_box.addWidget(self.txt_server_url)
srv_box.addWidget(btn_ping)
form.addRow("URL Servidor:", srv_box)
self.txt_reg_code = QLineEdit()
self.txt_reg_code.setPlaceholderText("Código generado en la Web (ej: OED-XXXX-XXXX)")
form.addRow("Código de Registro:", self.txt_reg_code)
btn_register = QPushButton("🚀 Registrar / Re-vincular Dispositivo")
btn_register.setProperty("class", "primary")
btn_register.clicked.connect(self._register_device_api)
form.addRow("", btn_register)
layout.addWidget(card_srv)
# Notifications Preferences Card (Clean & Non-invasive)
card_notif = QFrame()
card_notif.setProperty("class", "card")
notif_layout = QVBoxLayout(card_notif)
notif_layout.setSpacing(10)
lbl_notif = QLabel("Preferencias de Notificaciones en Windows")
lbl_notif.setFont(QFont("Segoe UI", 12, QFont.Weight.Bold))
notif_layout.addWidget(lbl_notif)
self.chk_notif_main = QCheckBox("Habilitar notificaciones en el Área de Notificaciones (System Tray)")
self.chk_notif_main.setChecked(self.config.enable_notifications)
self.chk_notif_main.stateChanged.connect(self._save_notif_settings)
notif_layout.addWidget(self.chk_notif_main)
self.chk_notif_start = QCheckBox("Notificar únicamente cuando INICIA un proceso de respaldo")
self.chk_notif_start.setChecked(self.config.notify_on_start)
self.chk_notif_start.stateChanged.connect(self._save_notif_settings)
notif_layout.addWidget(self.chk_notif_start)
self.chk_notif_complete = QCheckBox("Notificar únicamente cuando FINALIZA con éxito (Confirmación SHA-256)")
self.chk_notif_complete.setChecked(self.config.notify_on_complete)
self.chk_notif_complete.stateChanged.connect(self._save_notif_settings)
notif_layout.addWidget(self.chk_notif_complete)
self.chk_notif_error = QCheckBox("Notificar en caso de error o pérdida de conexión")
self.chk_notif_error.setChecked(self.config.notify_on_error)
self.chk_notif_error.stateChanged.connect(self._save_notif_settings)
notif_layout.addWidget(self.chk_notif_error)
layout.addWidget(card_notif)
layout.addStretch()
# --- TAB 4: HISTORIAL ---
def _build_tab_history(self):
layout = QVBoxLayout(self.tab_history)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(12)
lbl = QLabel("Historial de Archivos Respaldados Localmente")
lbl.setFont(QFont("Segoe UI", 12, QFont.Weight.Bold))
layout.addWidget(lbl)
self.tbl_history = QTableWidget(0, 4)
self.tbl_history.setHorizontalHeaderLabels(["Archivo", "Tamaño", "Integridad SHA-256", "Fecha de Respaldo"])
self.tbl_history.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
layout.addWidget(self.tbl_history)
self._refresh_history_table()
# --- TRAY ICON & WINDOW CLOSE BEHAVIOR ---
def _init_tray(self):
self.tray = QSystemTrayIcon(self)
self.tray.setIcon(self.app_icon)
self.tray.setToolTip(f"OnEver Drive — {self.config.client_code or 'Sin Registrar'}")
self._build_tray_menu()
self.tray.activated.connect(self._on_tray_activated)
self.tray.show()
def _build_tray_menu(self):
menu = QMenu()
client_label = self.config.client_code or "Sin Registrar"
act_title = QAction(f"OnEver Drive ({client_label})", self)
act_title.setEnabled(False)
menu.addAction(act_title)
menu.addSeparator()
act_open = QAction("🖥️ Abrir Panel de Control", self)
act_open.triggered.connect(self.showNormal)
menu.addAction(act_open)
act_sync = QAction("▶ Respaldar Todo Ahora", self)
act_sync.triggered.connect(self._trigger_all_backups)
menu.addAction(act_sync)
menu.addSeparator()
# Notification direct toggle in tray
self.act_tray_notif = QAction("🔔 Notificaciones Activadas" if self.config.enable_notifications else "🔕 Notificaciones Silenciadas", self)
self.act_tray_notif.triggered.connect(self._toggle_tray_notifications)
menu.addAction(self.act_tray_notif)
menu.addSeparator()
act_exit = QAction("❌ Salir", self)
act_exit.triggered.connect(self._clean_exit)
menu.addAction(act_exit)
self.tray.setContextMenu(menu)
def _toggle_tray_notifications(self):
self.config = load_config()
self.config.enable_notifications = not self.config.enable_notifications
save_config(self.config)
self.chk_notif_main.setChecked(self.config.enable_notifications)
self._build_tray_menu()
def _save_notif_settings(self):
self.config = load_config()
self.config.enable_notifications = self.chk_notif_main.isChecked()
self.config.notify_on_start = self.chk_notif_start.isChecked()
self.config.notify_on_complete = self.chk_notif_complete.isChecked()
self.config.notify_on_error = self.chk_notif_error.isChecked()
save_config(self.config)
self._build_tray_menu()
def _on_tray_activated(self, reason):
if reason == QSystemTrayIcon.ActivationReason.DoubleClick or reason == QSystemTrayIcon.ActivationReason.Trigger:
self.showNormal()
self.activateWindow()
def closeEvent(self, event):
"""Minimize silently to system tray on close without showing intrusive popups."""
event.ignore()
self.hide()
def _clean_exit(self):
self.daemon.stop()
QApplication.quit()
# --- ACTIONS & NOTIFICATION TRIGGERS (Discrete: Start & Finish only) ---
def _update_header_status(self):
self.config = load_config()
if self.config.client_code:
self.lbl_status_badge.setText(f"● Conectado ({self.config.client_code})")
self.lbl_status_badge.setStyleSheet("background-color: #065F46; color: #34D399; font-weight: bold; border-radius: 4px; padding: 6px 12px;")
self.lbl_dash_client.setText(f"Cliente ID: {self.config.client_code} ({self.config.client_name or 'Local'})")
self.lbl_dash_server.setText(f"Servidor Proxmox: {self.config.server_url}")
self.lbl_dash_folders.setText(f"Carpetas en Monitoreo: {len(self.config.local_folders)}")
else:
self.lbl_status_badge.setText("● Sin Registrar")
self.lbl_status_badge.setStyleSheet("background-color: #7F1D1D; color: #FCA5A5; font-weight: bold; border-radius: 4px; padding: 6px 12px;")
def _show_add_folder_dialog(self):
dialog = AddFolderDialog(self)
if dialog.exec() == QDialog.DialogCode.Accepted:
new_job = dialog.get_data()
self.config = load_config()
self.config.local_folders.append(new_job)
save_config(self.config)
self._refresh_folders_table()
self._update_header_status()
QMessageBox.information(self, "Carpeta Añadida", f"La carpeta '{new_job.name}' ha sido configurada y está siendo monitoreada.")
def _refresh_folders_table(self):
self.config = load_config()
self.tbl_folders.setRowCount(len(self.config.local_folders))
for row, job in enumerate(self.config.local_folders):
self.tbl_folders.setItem(row, 0, QTableWidgetItem(job.name))
self.tbl_folders.setItem(row, 1, QTableWidgetItem(job.source_path))
self.tbl_folders.setItem(row, 2, QTableWidgetItem(job.file_patterns))
self.tbl_folders.setItem(row, 3, QTableWidgetItem(f"{job.schedule_interval_minutes} min"))
self.tbl_folders.setItem(row, 4, QTableWidgetItem(job.last_status or "En espera"))
def _delete_selected_folder(self):
row = self.tbl_folders.currentRow()
if row < 0:
QMessageBox.warning(self, "Selección", "Por favor selecciona una carpeta para eliminar.")
return
job_name = self.tbl_folders.item(row, 0).text()
reply = QMessageBox.question(self, "Confirmar", f"¿Eliminar el monitoreo de la carpeta '{job_name}'?")
if reply == QMessageBox.StandardButton.Yes:
self.config = load_config()
if row < len(self.config.local_folders):
self.config.local_folders.pop(row)
save_config(self.config)
self._refresh_folders_table()
self._update_header_status()
def _test_server_connection(self):
url = self.txt_server_url.text().strip().rstrip("/")
if not url:
QMessageBox.warning(self, "Error", "Ingresa una URL de servidor.")
return
try:
t0 = time.time()
with httpx.Client(timeout=5.0) as client:
r = client.get(f"{url}/health")
elapsed_ms = int((time.time() - t0) * 1000)
if r.status_code == 200:
QMessageBox.information(self, "Conexión Exitosa", f"✓ Servidor OnEver Drive alcanzable.\nLatencia: {elapsed_ms} ms\nRespuesta: {r.json()}")
else:
QMessageBox.warning(self, "Error", f"El servidor respondió con código {r.status_code}")
except Exception as ex:
QMessageBox.critical(self, "Error de Conexión", f"No se pudo contactar al servidor en {url}:\n{str(ex)}")
def _register_device_api(self):
server_url = self.txt_server_url.text().strip().rstrip("/")
code = self.txt_reg_code.text().strip().upper()
if not server_url or not code:
QMessageBox.warning(self, "Error", "Debes ingresar la URL del servidor y el código de registro.")
return
try:
hostname = socket.gethostname()
os_info = f"{platform.system()} {platform.release()} (Build {platform.version()})"
payload = {
"registration_code": code,
"name": hostname,
"hostname": hostname,
"os_info": os_info,
"agent_version": "1.0.0"
}
with httpx.Client(timeout=15.0) as client:
resp = client.post(f"{server_url}/api/clients/register", json=payload)
if resp.status_code != 200:
QMessageBox.warning(self, "Registro Fallido", f"El servidor denegó el registro: {resp.text}")
return
data = resp.json()
self.config.server_url = server_url
self.config.client_code = data["client_code"]
self.config.device_id = data["device_id"]
self.config.device_token = data["device_token"]
self.config.client_name = data["name"]
save_config(self.config)
self._update_header_status()
self._build_tray_menu()
self.daemon.start()
QMessageBox.information(self, "Registro Exitoso", f"¡Dispositivo vinculado con éxito!\nCliente: {data['client_code']}")
except Exception as ex:
QMessageBox.critical(self, "Error", f"Error durante el registro: {str(ex)}")
def _trigger_all_backups(self):
if not self.config.device_id:
QMessageBox.warning(self, "Sin Registro", "Primero vincula el dispositivo en la pestaña Servidor & Config.")
return
threading.Thread(target=self.daemon._run_backup_cycle, daemon=True).start()
self.lbl_transfer_info.setText("Iniciando escaneo de carpetas y comprobación de locks...")
# --- DISCRETE NOTIFICATION HANDLERS (START & FINISH ONLY) ---
def _on_backup_started(self, filename: str, file_size: int):
mb = file_size / (1024 * 1024)
self.lbl_transfer_info.setText(f"Iniciando respaldo de: {filename} ({mb:.2f} MB)")
# Single notification at START of backup (if enabled)
if self.config.enable_notifications and self.config.notify_on_start:
self.tray.showMessage(
"OnEver Drive — Inicio de Respaldo",
f"Iniciando transferencia de {filename} ({mb:.2f} MB)...",
QSystemTrayIcon.MessageIcon.Information,
2500
)
def _on_live_progress(self, filename: str, done: int, total: int, pct: float):
# Progress updates ONLY update the GUI progressbar silently (no popups)
self.pbar_transfer.setValue(int(pct))
self.lbl_transfer_info.setText(f"Subiendo {filename}...")
self.lbl_chunk_details.setText(f"Chunks: {done} / {total} ({pct:.1f}%) | Motor por Bloques de 4MB Activo")
def _on_backup_completed(self, filename: str, sha256: str, file_size: int):
self.pbar_transfer.setValue(100)
self.lbl_transfer_info.setText(f"✓ Backup verificado e íntegro: {filename}")
self.lbl_chunk_details.setText(f"SHA-256: {sha256[:16]}... | Tamaño: {file_size / (1024*1024):.2f} MB")
# Single notification at FINISH of backup (if enabled)
if self.config.enable_notifications and self.config.notify_on_complete:
self.tray.showMessage(
"OnEver Drive — Respaldo Exitoso ✓",
f"{filename} respaldado y verificado en el servidor central (SHA-256).",
QSystemTrayIcon.MessageIcon.Information,
3000
)
self._refresh_history_table()
self._refresh_folders_table()
def _on_backup_error(self, filename: str, err: str):
self.lbl_transfer_info.setText(f"✗ Error al respaldar {filename}")
self.lbl_chunk_details.setText(f"Detalle: {err}")
# Notification on ERROR (if enabled)
if self.config.enable_notifications and self.config.notify_on_error:
self.tray.showMessage(
"OnEver Drive — Error en Respaldo ✗",
f"Fallo al respaldar {filename}: {err}",
QSystemTrayIcon.MessageIcon.Warning,
4000
)
def _on_daemon_status(self, status: str, message: str):
if status == "ONLINE":
self._update_header_status()
def _refresh_history_table(self):
try:
with state_db._get_conn() as conn:
cursor = conn.cursor()
cursor.execute("SELECT filepath, file_size, sha256, last_backup_time FROM completed_files ORDER BY last_backup_time DESC LIMIT 50")
rows = cursor.fetchall()
self.tbl_history.setRowCount(len(rows))
for r_idx, row in enumerate(rows):
self.tbl_history.setItem(r_idx, 0, QTableWidgetItem(Path(row[0]).name))
self.tbl_history.setItem(r_idx, 1, QTableWidgetItem(f"{row[1] / (1024*1024):.2f} MB"))
self.tbl_history.setItem(r_idx, 2, QTableWidgetItem(row[2][:16] + "..."))
self.tbl_history.setItem(r_idx, 3, QTableWidgetItem(str(row[3])))
except Exception:
pass
def main():
app = QApplication(sys.argv)
app.setQuitOnLastWindowClosed(False)
window = OnEverDriveMainWindow()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
+190
View File
@@ -0,0 +1,190 @@
import sys
import os
import argparse
import socket
import platform
import time
from pathlib import Path
import httpx
from colorama import init, Fore, Style
# Add agent directory to sys.path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from agent.config import load_config, save_config, AgentConfig
from agent.uploader import ChunkUploader
from agent.service import AgentDaemon
init(autoreset=True)
def print_banner():
print(Fore.CYAN + Style.BRIGHT + """
+------------------------------------------------------------------+
| ONEVER DRIVE - WINDOWS AGENT CLI |
| Enterprise Resilient Chunk Backup Engine for Windows |
+------------------------------------------------------------------+
""")
def cmd_register(args):
print_banner()
server_url = args.server.rstrip("/")
code = args.code.strip().upper()
hostname = socket.gethostname()
os_info = f"{platform.system()} {platform.release()} (Build {platform.version()})"
print(Fore.YELLOW + f"Connecting to {server_url} with registration code: {code}...")
payload = {
"registration_code": code,
"name": args.name or hostname,
"hostname": hostname,
"os_info": os_info,
"agent_version": "1.0.0"
}
try:
with httpx.Client(timeout=30.0) as client:
resp = client.post(f"{server_url}/api/clients/register", json=payload)
if resp.status_code != 200:
print(Fore.RED + f"Registration failed ({resp.status_code}): {resp.text}")
sys.exit(1)
data = resp.json()
config = load_config()
config.server_url = server_url
config.client_code = data["client_code"]
config.device_id = data["device_id"]
config.device_token = data["device_token"]
config.client_name = data["name"]
save_config(config)
print(Fore.GREEN + Style.BRIGHT + "\n[+] Agent registered successfully!")
print(Fore.WHITE + f" Client Code : {data['client_code']}")
print(Fore.WHITE + f" Device ID : {data['device_id']}")
print(Fore.WHITE + f" Client Name : {data['name']}")
print(Fore.CYAN + "\nYou can now start the agent daemon or run manual backups.")
except Exception as ex:
print(Fore.RED + f"Error connecting to server: {str(ex)}")
sys.exit(1)
def cmd_status(args):
print_banner()
config = load_config()
if not config.device_id:
print(Fore.YELLOW + "Agent is NOT registered yet. Run 'agent_cli.py register' first.")
return
print(Fore.GREEN + "[*] Agent Configuration:")
print(f" Server URL : {config.server_url}")
print(f" Client Code : {config.client_code}")
print(f" Device ID : {config.device_id}")
print(f" Client Name : {config.client_name}")
print(f" Chunk Size : {config.chunk_size / (1024*1024):.1f} MB")
print(Fore.CYAN + "\n[*] Testing connection to server...")
try:
headers = {
"X-Device-Id": config.device_id,
"X-Device-Token": config.device_token
}
with httpx.Client(base_url=config.server_url, headers=headers, timeout=10.0) as client:
resp = client.get("/api/jobs/agent/assigned")
if resp.status_code == 200:
jobs = resp.json()
print(Fore.GREEN + f" Connection OK. Assigned jobs count: {len(jobs)}")
for j in jobs:
print(Fore.WHITE + f" - [{j['job_code']}] {j['name']} ({j['source_path']} | {j['file_patterns']})")
else:
print(Fore.RED + f" Server returned {resp.status_code}: {resp.text}")
except Exception as ex:
print(Fore.RED + f" Connection failed: {str(ex)}")
def cmd_backup(args):
print_banner()
filepath = Path(args.file).resolve()
if not filepath.exists():
print(Fore.RED + f"File not found: {filepath}")
sys.exit(1)
config = load_config()
if not config.device_id:
print(Fore.RED + "Agent is not registered. Run registration first.")
sys.exit(1)
print(Fore.CYAN + f"[*] Initiating chunked backup for: {filepath.name}")
print(f" File size : {filepath.stat().st_size / (1024*1024):.2f} MB")
print(f" Chunk size : {config.chunk_size / (1024*1024):.1f} MB")
uploader = ChunkUploader(config)
start_time = time.time()
def print_progress(received, total, pct):
bar_len = 30
filled = int(bar_len * (pct / 100))
bar = "#" * filled + "-" * (bar_len - filled)
sys.stdout.write(f"\r{Fore.YELLOW}Progress: [{bar}] {pct:.1f}% ({received}/{total} chunks)")
sys.stdout.flush()
try:
result = uploader.upload_file(filepath, job_id=args.job, progress_callback=print_progress)
elapsed = max(0.01, time.time() - start_time)
mb = filepath.stat().st_size / (1024 * 1024)
speed = mb / elapsed
print(Fore.GREEN + Style.BRIGHT + f"\n\n[+] Backup Complete & Verified!")
print(Fore.WHITE + f" Remote Path : {result.get('relative_path')}")
print(Fore.WHITE + f" SHA-256 : {result.get('sha256')}")
print(Fore.WHITE + f" Transfer : {mb:.2f} MB in {elapsed:.2f}s ({speed:.2f} MB/s)")
except Exception as ex:
print(Fore.RED + f"\n[-] Backup failed: {str(ex)}")
sys.exit(1)
def cmd_daemon(args):
print_banner()
config = load_config()
daemon = AgentDaemon(config)
daemon.start()
print(Fore.GREEN + "[*] Agent daemon running. Press Ctrl+C to stop.")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
daemon.stop()
print(Fore.YELLOW + "\nAgent stopped.")
def main():
parser = argparse.ArgumentParser(description="OnEver Drive Windows Agent CLI")
subparsers = parser.add_subparsers(dest="command")
# Register
p_reg = subparsers.add_parser("register", help="Register agent with central server")
p_reg.add_argument("--server", required=True, help="Server URL (e.g. http://192.168.1.100:8000)")
p_reg.add_argument("--code", required=True, help="Registration code (e.g. OED-A1B2-C3D4)")
p_reg.add_argument("--name", help="Custom name for this client machine")
p_reg.set_defaults(func=cmd_register)
# Status
p_stat = subparsers.add_parser("status", help="Show current agent status and server connectivity")
p_stat.set_defaults(func=cmd_status)
# Backup
p_bak = subparsers.add_parser("backup", help="Perform manual chunked backup of a file")
p_bak.add_argument("--file", required=True, help="Path to file to back up")
p_bak.add_argument("--job", type=int, help="Optional Backup Job ID")
p_bak.set_defaults(func=cmd_backup)
# Daemon
p_daemon = subparsers.add_parser("daemon", help="Run the background worker loop in foreground")
p_daemon.set_defaults(func=cmd_daemon)
args = parser.parse_args()
if hasattr(args, "func"):
args.func(args)
else:
parser.print_help()
if __name__ == "__main__":
main()
+301
View File
@@ -0,0 +1,301 @@
import os
import sys
import threading
import time
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
from pathlib import Path
import httpx
# Add agent root to sys.path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from agent.config import load_config, save_config, AgentConfig
from agent.uploader import ChunkUploader
from agent.chunker import compute_file_sha256
class AgentGuiApp:
"""Lightweight Windows GUI control panel for OnEver Drive."""
def __init__(self, root: tk.Tk):
self.root = root
self.root.title("OnEver Drive — Agente Windows")
self.root.geometry("640x520")
self.root.resizable(False, False)
# Style configuration
self.config = load_config()
self._apply_dark_theme()
# Build UI layout
self._build_header()
self._build_tabs()
self._refresh_status()
def _apply_dark_theme(self):
self.root.configure(bg="#0F172A")
style = ttk.Style()
style.theme_use("clam")
# Configure colors
style.configure("TNotebook", background="#0F172A", borderwidth=0)
style.configure("TNotebook.Tab", background="#1E293B", foreground="#94A3B8", padding=[16, 8], font=("Segoe UI", 9, "bold"))
style.map("TNotebook.Tab", background=[("selected", "#06B6D4")], foreground=[("selected", "#FFFFFF")])
style.configure("TFrame", background="#0F172A")
style.configure("Card.TFrame", background="#1E293B", relief="flat")
style.configure("TLabel", background="#0F172A", foreground="#F8FAFC", font=("Segoe UI", 9))
style.configure("Card.TLabel", background="#1E293B", foreground="#F8FAFC", font=("Segoe UI", 9))
style.configure("Dim.TLabel", background="#1E293B", foreground="#94A3B8", font=("Segoe UI", 8))
style.configure("Header.TLabel", background="#0F172A", foreground="#FFFFFF", font=("Segoe UI", 12, "bold"))
style.configure("Primary.TButton", background="#06B6D4", foreground="#FFFFFF", font=("Segoe UI", 9, "bold"), borderwidth=0, padding=6)
style.map("Primary.TButton", background=[("active", "#0891B2")])
style.configure("Secondary.TButton", background="#334155", foreground="#FFFFFF", font=("Segoe UI", 9), borderwidth=0, padding=6)
style.map("Secondary.TButton", background=[("active", "#475569")])
style.configure("TProgressbar", thickness=10, background="#06B6D4", troughcolor="#334155", borderwidth=0)
def _build_header(self):
header_frame = ttk.Frame(self.root, padding=16)
header_frame.pack(fill="x")
title_lbl = ttk.Label(header_frame, text="OnEver Drive — Agente Windows", style="Header.TLabel")
title_lbl.pack(side="left")
self.status_badge = tk.Label(header_frame, text="● En Línea", bg="#065F46", fg="#34D399", font=("Segoe UI", 8, "bold"), padx=8, pady=3)
self.status_badge.pack(side="right")
def _build_tabs(self):
self.notebook = ttk.Notebook(self.root)
self.notebook.pack(fill="both", expand=True, padx=16, pady=(0, 16))
# Tab 1: Estado y Dispositivo
self.tab_status = ttk.Frame(self.notebook, padding=16)
self.notebook.add(self.tab_status, text="Estado")
self._build_tab_status()
# Tab 2: Trabajos de Backup
self.tab_jobs = ttk.Frame(self.notebook, padding=16)
self.notebook.add(self.tab_jobs, text="Trabajos Asignados")
self._build_tab_jobs()
# Tab 3: Respaldo Manual
self.tab_manual = ttk.Frame(self.notebook, padding=16)
self.notebook.add(self.tab_manual, text="Backup Manual")
self._build_tab_manual()
# Tab 4: Registro / Configuración
self.tab_config = ttk.Frame(self.notebook, padding=16)
self.notebook.add(self.tab_config, text="Configuración")
self._build_tab_config()
def _build_tab_status(self):
card = ttk.Frame(self.tab_status, style="Card.TFrame", padding=16)
card.pack(fill="both", expand=True)
self.lbl_client_code = ttk.Label(card, text="Cliente ID: —", style="Card.TLabel", font=("Segoe UI", 10, "bold"))
self.lbl_client_code.pack(anchor="w", pady=(0, 4))
self.lbl_client_name = ttk.Label(card, text="Nombre: —", style="Dim.TLabel")
self.lbl_client_name.pack(anchor="w", pady=2)
self.lbl_server_url = ttk.Label(card, text="Servidor: —", style="Dim.TLabel")
self.lbl_server_url.pack(anchor="w", pady=2)
self.lbl_device_id = ttk.Label(card, text="Device ID: —", style="Dim.TLabel")
self.lbl_device_id.pack(anchor="w", pady=2)
ttk.Separator(card).pack(fill="x", pady=12)
ttk.Label(card, text="Motor de Transferencia:", style="Card.TLabel", font=("Segoe UI", 9, "bold")).pack(anchor="w")
ttk.Label(card, text="• Transferencia por bloques de 4 MB\n• Reanudación automática ante microcortes\n• Verificación estricta de integridad SHA-256\n• Detección de archivos en uso (Locks de SQL Server)", style="Dim.TLabel").pack(anchor="w", pady=6)
btn_refresh = ttk.Button(card, text="Actualizar Estado", style="Secondary.TButton", command=self._refresh_status)
btn_refresh.pack(anchor="e", pady=(12, 0))
def _build_tab_jobs(self):
self.jobs_container = ttk.Frame(self.tab_jobs, style="Card.TFrame", padding=12)
self.jobs_container.pack(fill="both", expand=True)
self.jobs_list_lbl = ttk.Label(self.jobs_container, text="Cargando trabajos asignados por el servidor...", style="Dim.TLabel")
self.jobs_list_lbl.pack(anchor="w", pady=10)
def _build_tab_manual(self):
card = ttk.Frame(self.tab_manual, style="Card.TFrame", padding=16)
card.pack(fill="both", expand=True)
ttk.Label(card, text="Selecciona un archivo local para realizar un backup por chunks:", style="Card.TLabel").pack(anchor="w", pady=(0, 8))
file_frame = ttk.Frame(card, style="Card.TFrame")
file_frame.pack(fill="x", pady=4)
self.entry_file = tk.Entry(file_frame, bg="#0F172A", fg="#FFFFFF", insertbackground="#FFFFFF", relief="flat", font=("Segoe UI", 9))
self.entry_file.pack(side="left", fill="x", expand=True, ipady=6, padx=(0, 8))
btn_browse = ttk.Button(file_frame, text="Explorar...", style="Secondary.TButton", command=self._browse_file)
btn_browse.pack(side="right")
self.btn_upload = ttk.Button(card, text="Iniciar Backup Inmediato", style="Primary.TButton", command=self._start_manual_backup)
self.btn_upload.pack(fill="x", pady=16)
self.lbl_progress = ttk.Label(card, text="Estado: En espera", style="Dim.TLabel")
self.lbl_progress.pack(anchor="w", pady=(0, 4))
self.progressbar = ttk.Progressbar(card, style="TProgressbar", mode="determinate")
self.progressbar.pack(fill="x", pady=(0, 8))
def _build_tab_config(self):
card = ttk.Frame(self.tab_config, style="Card.TFrame", padding=16)
card.pack(fill="both", expand=True)
ttk.Label(card, text="Servidor Central (URL):", style="Card.TLabel").pack(anchor="w")
self.entry_server = tk.Entry(card, bg="#0F172A", fg="#FFFFFF", insertbackground="#FFFFFF", relief="flat", font=("Segoe UI", 9))
self.entry_server.insert(0, self.config.server_url)
self.entry_server.pack(fill="x", ipady=6, pady=(4, 12))
ttk.Label(card, text="Código de Registro (generado en Web UI):", style="Card.TLabel").pack(anchor="w")
self.entry_reg_code = tk.Entry(card, bg="#0F172A", fg="#FFFFFF", insertbackground="#FFFFFF", relief="flat", font=("Segoe UI", 9))
self.entry_reg_code.pack(fill="x", ipady=6, pady=(4, 16))
btn_register = ttk.Button(card, text="Registrar Dispositivo", style="Primary.TButton", command=self._register_device)
btn_register.pack(fill="x")
def _browse_file(self):
filename = filedialog.askopenfilename(title="Seleccionar archivo para backup", filetypes=[("Archivos SQL Server / Todos", "*.bak;*.mdf;*.*")])
if filename:
self.entry_file.delete(0, tk.END)
self.entry_file.insert(0, filename)
def _start_manual_backup(self):
filepath_str = self.entry_file.get().strip()
if not filepath_str or not os.path.exists(filepath_str):
messagebox.showerror("Error", "Por favor selecciona un archivo existente.")
return
if not self.config.device_id:
messagebox.showerror("Error", "El agente no está registrado contra el servidor.")
return
self.btn_upload.configure(state="disabled")
self.lbl_progress.configure(text="Iniciando subida por bloques...")
self.progressbar["value"] = 0
def worker():
try:
filepath = Path(filepath_str)
uploader = ChunkUploader(self.config)
def on_progress(done, total, pct):
self.root.after(0, lambda: self._update_progress_ui(done, total, pct, filepath.name))
res = uploader.upload_file(filepath, progress_callback=on_progress)
self.root.after(0, lambda: self._on_backup_success(filepath.name, res))
except Exception as ex:
self.root.after(0, lambda: self._on_backup_error(str(ex)))
threading.Thread(target=worker, daemon=True).start()
def _update_progress_ui(self, done, total, pct, filename):
self.progressbar["value"] = pct
self.lbl_progress.configure(text=f"Subiendo {filename}: {done}/{total} chunks ({pct:.1f}%)")
def _on_backup_success(self, filename, res):
self.btn_upload.configure(state="normal")
self.progressbar["value"] = 100
self.lbl_progress.configure(text=f"✓ Backup completado y verificado: {filename}")
messagebox.showinfo("Éxito", f"¡Backup completado con éxito!\n\nArchivo: {filename}\nSHA-256: {res.get('sha256')}\nRuta remota: {res.get('relative_path')}")
def _on_backup_error(self, err_msg):
self.btn_upload.configure(state="normal")
self.lbl_progress.configure(text=f"✗ Error: {err_msg}")
messagebox.showerror("Error de Backup", f"Fallo al respaldar archivo:\n{err_msg}")
def _register_device(self):
server_url = self.entry_server.get().strip().rstrip("/")
code = self.entry_reg_code.get().strip().upper()
if not server_url or not code:
messagebox.showerror("Error", "Debes ingresar la URL del servidor y el código de registro.")
return
try:
import socket, platform
hostname = socket.gethostname()
os_info = f"{platform.system()} {platform.release()} (Build {platform.version()})"
payload = {
"registration_code": code,
"name": hostname,
"hostname": hostname,
"os_info": os_info,
"agent_version": "1.0.0"
}
with httpx.Client(timeout=15.0) as client:
resp = client.post(f"{server_url}/api/clients/register", json=payload)
if resp.status_code != 200:
messagebox.showerror("Error de Registro", f"El servidor respondió: {resp.text}")
return
data = resp.json()
self.config.server_url = server_url
self.config.client_code = data["client_code"]
self.config.device_id = data["device_id"]
self.config.device_token = data["device_token"]
self.config.client_name = data["name"]
save_config(self.config)
messagebox.showinfo("Registro Exitoso", f"¡Dispositivo registrado!\nCliente: {data['client_code']}\nID: {data['device_id']}")
self._refresh_status()
except Exception as ex:
messagebox.showerror("Error de Conexión", f"No se pudo conectar al servidor:\n{str(ex)}")
def _refresh_status(self):
self.config = load_config()
if self.config.client_code:
self.lbl_client_code.configure(text=f"Cliente ID: {self.config.client_code}")
self.lbl_client_name.configure(text=f"Nombre: {self.config.client_name or ''}")
self.lbl_server_url.configure(text=f"Servidor: {self.config.server_url}")
self.lbl_device_id.configure(text=f"Device ID: {self.config.device_id}")
self.status_badge.configure(text="● Registrado", bg="#065F46", fg="#34D399")
self._fetch_jobs()
else:
self.lbl_client_code.configure(text="Cliente ID: No Registrado")
self.status_badge.configure(text="● Sin Registro", bg="#7F1D1D", fg="#FCA5A5")
def _fetch_jobs(self):
def worker():
try:
headers = {"X-Device-Id": self.config.device_id, "X-Device-Token": self.config.device_token}
with httpx.Client(base_url=self.config.server_url, headers=headers, timeout=10.0) as client:
resp = client.get("/api/jobs/agent/assigned")
if resp.status_code == 200:
jobs = resp.json()
self.root.after(0, lambda: self._render_jobs_list(jobs))
except Exception:
pass
threading.Thread(target=worker, daemon=True).start()
def _render_jobs_list(self, jobs):
for widget in self.jobs_container.winfo_children():
widget.destroy()
if not jobs:
ttk.Label(self.jobs_container, text="No hay trabajos programados asignados a este equipo.", style="Dim.TLabel").pack(pady=20)
return
for j in jobs:
item_frame = ttk.Frame(self.jobs_container, style="Card.TFrame", padding=8)
item_frame.pack(fill="x", pady=4)
ttk.Label(item_frame, text=f"[{j['job_code']}] {j['name']}", style="Card.TLabel", font=("Segoe UI", 9, "bold")).pack(anchor="w")
ttk.Label(item_frame, text=f"Ruta: {j['source_path']} | Filtros: {j['file_patterns']} | Cron: {j['schedule_cron']}", style="Dim.TLabel").pack(anchor="w")
def launch_gui():
root = tk.Tk()
app = AgentGuiApp(root)
root.mainloop()
if __name__ == "__main__":
launch_gui()
+140
View File
@@ -0,0 +1,140 @@
import os
import sys
import threading
import time
from pathlib import Path
from PIL import Image
import pystray
from pystray import MenuItem as item
# Add agent root to sys.path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from agent.config import load_config, AgentConfig
from agent.service import AgentDaemon
from agent.uploader import ChunkUploader
from create_icons import generate_app_icons
class WindowsTrayAgent:
"""Windows System Tray (Área de Notificaciones) Application for OnEver Drive."""
def __init__(self):
self.config = load_config()
self.daemon = AgentDaemon(self.config)
self.icon = None
self.is_paused = False
self.icon_image = self._load_icon()
def _load_icon(self) -> Image.Image:
assets_dir = Path(__file__).resolve().parent / "assets"
png_path = assets_dir / "icon.png"
if not png_path.exists():
_, png_path = generate_app_icons()
return Image.open(png_path)
def _get_status_text(self) -> str:
if not self.config.client_code:
return "Estado: Sin Registrar"
if self.is_paused:
return "Estado: Pausado"
return f"Estado: Conectado ({self.config.client_code})"
def _toggle_pause(self, icon, item_obj):
self.is_paused = not self.is_paused
if self.is_paused:
self.daemon.stop()
self.notify("Sincronización en pausa", "El servicio de backup ha sido pausado.")
else:
self.daemon.start()
self.notify("Sincronización activa", "El servicio de backup se ha reanudado.")
def _open_gui(self, icon=None, item_obj=None):
def run_gui():
from agent_gui import launch_gui
launch_gui()
threading.Thread(target=run_gui, daemon=True).start()
def _manual_backup(self, icon=None, item_obj=None):
import tkinter as tk
from tkinter import filedialog, messagebox
def run_picker():
root = tk.Tk()
root.withdraw()
filepath = filedialog.askopenfilename(
title="Seleccionar archivo para backup",
filetypes=[("Archivos SQL / Datos", "*.bak;*.mdf;*.*")]
)
if not filepath:
root.destroy()
return
self.notify("Iniciando Backup", f"Preparando transferencia por chunks: {Path(filepath).name}")
def upload_worker():
try:
uploader = ChunkUploader(self.config)
res = uploader.upload_file(Path(filepath))
self.notify("Backup Completado ✓", f"{Path(filepath).name} verificado con éxito en el servidor.")
except Exception as ex:
self.notify("Error en Backup ✗", f"Fallo al subir {Path(filepath).name}: {str(ex)}")
threading.Thread(target=upload_worker, daemon=True).start()
root.destroy()
threading.Thread(target=run_picker, daemon=True).start()
def notify(self, title: str, message: str):
"""Displays a native Windows Notification Balloon."""
if self.icon:
try:
self.icon.notify(message, title)
except Exception:
pass
def _on_exit(self, icon, item_obj):
self.daemon.stop()
icon.stop()
def build_menu(self):
client_title = f"OnEver Drive ({self.config.client_name or 'Agente'})"
return pystray.Menu(
item(client_title, lambda: None, enabled=False),
item(lambda text: self._get_status_text(), lambda: None, enabled=False),
pystray.Menu.SEPARATOR,
item("Abrir Panel de Control...", self._open_gui, default=True),
item("Hacer Backup Manual...", self._manual_backup),
item(lambda text: "Reanudar Servicio" if self.is_paused else "Pausar Servicio", self._toggle_pause),
pystray.Menu.SEPARATOR,
item("Salir", self._on_exit)
)
def run(self):
# Start background daemon worker
if self.config.device_id:
self.daemon.start()
# Create tray icon
self.icon = pystray.Icon(
name="OnEverDrive",
icon=self.icon_image,
title="OnEver Drive — Agente de Backup",
menu=self.build_menu()
)
# Notify on startup
if self.config.client_code:
self.notify("OnEver Drive Activo", f"Agente en ejecución ({self.config.client_code})")
else:
self.notify("OnEver Drive", "Agente iniciado. Requiere registro en el servidor.")
# Run system tray event loop
self.icon.run()
def main():
agent = WindowsTrayAgent()
agent.run()
if __name__ == "__main__":
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

+93
View File
@@ -0,0 +1,93 @@
import os
import sys
import subprocess
from pathlib import Path
def build_windows_exe():
agent_dir = Path(__file__).resolve().parent
assets_dir = agent_dir / "assets"
ico_path = assets_dir / "icon.ico"
if not ico_path.exists():
print("[*] Generating icons...")
from create_icons import generate_app_icons
generate_app_icons()
print("========================================================================")
print(" BUILDING ONEVER DRIVE MODERN PYQT6 WINDOWS EXECUTABLE (.EXE) ")
print("========================================================================")
dist_dir = agent_dir / "dist"
build_dir = agent_dir / "build"
# PyInstaller arguments for modern PyQt6 standalone agent
pyinstaller_args = [
sys.executable,
"-m",
"PyInstaller",
"--noconfirm",
"--onedir",
"--windowed", # No console popup, pure GUI and tray
f"--icon={str(ico_path)}",
f"--name=OnEverDriveAgent",
f"--distpath={str(dist_dir)}",
f"--workpath={str(build_dir)}",
f"--add-data={str(assets_dir)}{os.pathsep}assets",
"--hidden-import=PyQt6",
"--hidden-import=PyQt6.QtCore",
"--hidden-import=PyQt6.QtGui",
"--hidden-import=PyQt6.QtWidgets",
"--hidden-import=httpx",
"--hidden-import=pydantic",
"--hidden-import=pydantic_settings",
"--hidden-import=schedule",
"--hidden-import=sqlite3",
str(agent_dir / "agent_app_pyqt.py")
]
print(f"[*] Compiling PyQt6 directory bundle...")
subprocess.run(pyinstaller_args, check=True, cwd=str(agent_dir))
# Single-file build
standalone_args = [
sys.executable,
"-m",
"PyInstaller",
"--noconfirm",
"--onefile",
"--windowed",
f"--icon={str(ico_path)}",
f"--name=OnEverDriveAgent-Standalone",
f"--distpath={str(dist_dir)}",
f"--workpath={str(build_dir)}",
f"--add-data={str(assets_dir)}{os.pathsep}assets",
"--hidden-import=PyQt6",
"--hidden-import=PyQt6.QtCore",
"--hidden-import=PyQt6.QtGui",
"--hidden-import=PyQt6.QtWidgets",
"--hidden-import=httpx",
"--hidden-import=pydantic",
"--hidden-import=pydantic_settings",
"--hidden-import=schedule",
"--hidden-import=sqlite3",
str(agent_dir / "agent_app_pyqt.py")
]
print(f"[*] Compiling standalone single-file .exe...")
subprocess.run(standalone_args, check=True, cwd=str(agent_dir))
exe_path = dist_dir / "OnEverDriveAgent" / "OnEverDriveAgent.exe"
single_exe_path = dist_dir / "OnEverDriveAgent-Standalone.exe"
print("")
echo = "========================================================================"
print(echo)
print(f"[+] PyQt6 Executables built successfully:")
print(f" 1. {exe_path}")
print(f" 2. {single_exe_path}")
print(echo)
return exe_path
if __name__ == "__main__":
build_windows_exe()
+41
View File
@@ -0,0 +1,41 @@
from PIL import Image, ImageDraw
from pathlib import Path
def generate_app_icons():
assets_dir = Path(__file__).resolve().parent / "assets"
assets_dir.mkdir(parents=True, exist_ok=True)
size = (256, 256)
img = Image.new("RGBA", size, (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# Draw rounded shield / background
# Gradient/Cyan-Indigo background rounded rect
draw.rounded_rectangle([(16, 16), (240, 240)], radius=48, fill="#0F172A", outline="#06B6D4", width=8)
# Draw cloud / shield icon
# Cloud base
draw.ellipse([(60, 110), (140, 170)], fill="#06B6D4")
draw.ellipse([(110, 80), (190, 160)], fill="#38BDF8")
draw.ellipse([(140, 110), (200, 170)], fill="#60A5FA")
draw.rectangle([(100, 130), (170, 170)], fill="#06B6D4")
# Draw upward upload arrow inside cloud
# Arrow head
draw.polygon([(145, 110), (120, 135), (170, 135)], fill="#FFFFFF")
# Arrow body
draw.rectangle([(137, 135), (153, 160)], fill="#FFFFFF")
# Save PNG
png_path = assets_dir / "icon.png"
img.save(png_path, "PNG")
# Save ICO
ico_path = assets_dir / "icon.ico"
img.save(ico_path, format="ICO", sizes=[(16, 16), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)])
print(f"Icons generated at: {png_path} and {ico_path}")
return ico_path, png_path
if __name__ == "__main__":
generate_app_icons()
@@ -0,0 +1,69 @@
<#
.SYNOPSIS
OnEver Drive — Windows Agent Service Installer
.DESCRIPTION
Installs and configures the OnEver Drive Windows Agent as a background system service.
#>
param (
[string]$ServerUrl = "http://127.0.0.1:8000",
[string]$RegistrationCode = "",
[string]$ClientName = ""
)
Write-Host "==========================================================" -ForegroundColor Cyan
Write-Host " OnEver Drive — Windows Service Setup Script " -ForegroundColor Cyan
Write-Host "==========================================================" -ForegroundColor Cyan
$CurrentDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$AgentDir = Split-Path -Parent $CurrentDir
$PythonExe = (Get-Command python.exe -ErrorAction SilentlyContinue).Source
if (-not $PythonExe) {
Write-Error "Python 3.10+ was not found on PATH. Please install Python first."
exit 1
}
Write-Host "[+] Detected Python executable: $PythonExe" -ForegroundColor Green
Write-Host "[+] Agent root directory: $AgentDir" -ForegroundColor Green
# 1. If registration code provided, perform initial registration
if ($RegistrationCode -ne "") {
Write-Host "[*] Registering agent against server: $ServerUrl..." -ForegroundColor Yellow
$RegArgs = @("$AgentDir\agent_cli.py", "register", "--server", $ServerUrl, "--code", $RegistrationCode)
if ($ClientName -ne "") {
$RegArgs += @("--name", $ClientName)
}
& $PythonExe $RegArgs
if ($LASTEXITCODE -ne 0) {
Write-Error "Registration failed. Please check registration code and server connectivity."
exit 1
}
}
# 2. Service definition
$ServiceName = "OnEverDriveAgent"
$ServiceDisplayName = "OnEver Drive Backup Agent Service"
$ServiceDescription = "Enterprise chunked backup and sync daemon for OnEver Drive Proxmox platform."
$ServiceBinary = "`"$PythonExe`" `"$AgentDir\agent_cli.py`" daemon"
Write-Host "[*] Registering Windows Service: $ServiceName..." -ForegroundColor Yellow
# Stop and remove existing service if present
$ExistingService = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($ExistingService) {
Write-Host "[-] Stopping and removing existing service..." -ForegroundColor Yellow
Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue
sc.exe delete $ServiceName
Start-Sleep -Seconds 2
}
# Create service using sc.exe
sc.exe create $ServiceName binPath= $ServiceBinary start= auto DisplayName= $ServiceDisplayName
sc.exe description $ServiceName $ServiceDescription
# Configure recovery options (restart service on crash)
sc.exe failure $ServiceName reset= 86400 actions= restart/60000/restart/60000/restart/60000
Write-Host "[+] Service '$ServiceName' registered successfully!" -ForegroundColor Green
Write-Host "[*] To start the service run: Start-Service $ServiceName" -ForegroundColor Cyan
+5
View File
@@ -0,0 +1,5 @@
httpx>=0.27.0
pydantic>=2.6.0
pydantic-settings>=2.2.0
schedule>=1.2.1
colorama>=0.4.6
+6
View File
@@ -0,0 +1,6 @@
@echo off
title OnEver Drive - Windows Agent Launcher (PyQt6)
cd /d "%~dp0"
echo Starting OnEver Drive PyQt6 Agent in notification area...
start "" "%~dp0dist\OnEverDriveAgent\OnEverDriveAgent.exe"
exit