""" Installation and configuration utilities for TimeTracker This module handles first-time setup, installation-specific configuration, telemetry salt generation, and install identity (UUID) for base telemetry. """ import hashlib import json import os import secrets import uuid as uuid_module from pathlib import Path from typing import Dict, Optional class InstallationConfig: """Manages installation-specific configuration""" CONFIG_DIR = "/data" # default; overridden by INSTALLATION_CONFIG_DIR when set CONFIG_FILE = "installation.json" def __init__(self): effective_dir = os.environ.get("INSTALLATION_CONFIG_DIR", self.CONFIG_DIR) self.config_path = os.path.join(effective_dir, self.CONFIG_FILE) self._ensure_config_dir(effective_dir) self._config = self._load_config() def _ensure_config_dir(self, config_dir=None): """Ensure the configuration directory exists.""" dir_path = config_dir if config_dir is not None else os.environ.get("INSTALLATION_CONFIG_DIR", self.CONFIG_DIR) os.makedirs(dir_path, exist_ok=True) def _load_config(self) -> Dict: """Load configuration from file""" if os.path.exists(self.config_path): try: with open(self.config_path, "r") as f: return json.load(f) except Exception: return {} return {} def _save_config(self): """Save configuration to file. Merges with on-disk state so existing keys (e.g. setup_complete) are never dropped. """ try: on_disk = self._load_config() self._config = {**on_disk, **self._config} with open(self.config_path, "w") as f: json.dump(self._config, f, indent=2) except Exception as e: print(f"Error saving installation config: {e}") def get_installation_salt(self) -> str: """ Get or generate installation-specific salt for telemetry. This salt is unique per installation and persists across restarts. It's used to generate consistent anonymous fingerprints. """ if "telemetry_salt" not in self._config: # Generate a unique 64-character hex salt salt = secrets.token_hex(32) # 32 bytes = 64 hex characters self._config["telemetry_salt"] = salt self._save_config() return self._config["telemetry_salt"] def get_install_id(self) -> str: """ Get or generate a random installation UUID for telemetry. Used for base_telemetry and (when opt-in) as install-level identity in detailed analytics. Not derived from hostname or other identifying data. """ if "install_id" not in self._config: self._config["install_id"] = str(uuid_module.uuid4()) self._save_config() return self._config["install_id"] def get_installation_id(self) -> str: """ Get installation ID for display and backward compatibility. Returns the same canonical install identity as get_install_id() (UUID). """ return self.get_install_id() def is_setup_complete(self) -> bool: """Check if initial setup is complete""" return self._config.get("setup_complete", False) def mark_setup_complete(self, telemetry_enabled: bool = False): """Mark initial setup as complete""" self._config["setup_complete"] = True self._config["telemetry_enabled"] = telemetry_enabled self._config["setup_completed_at"] = str(datetime.now()) self._save_config() def is_initial_data_seeded(self) -> bool: """Check if initial database data (default client/project) has been seeded""" return self._config.get("initial_data_seeded", False) def mark_initial_data_seeded(self): """Mark that initial database data has been seeded""" self._config["initial_data_seeded"] = True self._config["initial_data_seeded_at"] = str(datetime.now()) self._save_config() def get_telemetry_preference(self) -> bool: """Get user's telemetry preference. Uses a local load only; does not overwrite self._config to avoid poisoning the shared in-memory state when the on-disk file is corrupt or empty. """ data = self._load_config() return data.get("telemetry_enabled", False) def set_telemetry_preference(self, enabled: bool): """Set user's telemetry preference""" self._config["telemetry_enabled"] = enabled self._save_config() def get_all_config(self) -> Dict: """Get all configuration (for admin dashboard)""" return self._config.copy() def get_base_first_seen_sent_at(self) -> Optional[str]: """Return ISO timestamp when base telemetry first_seen was sent, or None.""" return self._config.get("base_first_seen_sent_at") def set_base_first_seen_sent_at(self, iso_timestamp: str) -> None: """Record that base_telemetry.first_seen was sent. Persists to disk.""" self._config["base_first_seen_sent_at"] = iso_timestamp self._config["base_first_seen_at"] = iso_timestamp self._save_config() # Global instance _installation_config = None _installation_config_path = None def get_installation_config() -> InstallationConfig: """Get the global installation configuration instance""" global _installation_config, _installation_config_path # Reinitialize if config path changed (e.g., tests overriding directories) tmp = InstallationConfig() current_path = tmp.config_path if (_installation_config is None) or (_installation_config_path != current_path): _installation_config = tmp _installation_config_path = current_path return _installation_config # Add missing datetime import from datetime import datetime