feat: add version updater and statistics enhancements (fixes #29)
- Implemented version checking system with update notifications - Integrated Git for fetching and downloading the latest version - Added statistics updates - Removed unused code across the project - Condensed project structure - Updated README - Cleaned up UI
This commit is contained in:
@@ -336,10 +336,3 @@ def load_env_json(key: str, default: str = "[]") -> Any:
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to parse {key}: {e}")
|
||||
return json.loads(default)
|
||||
|
||||
|
||||
# Backwards compatibility aliases (deprecated - use get_system_value instead)
|
||||
get_protected_value = get_system_value
|
||||
get_protected_json = get_system_json
|
||||
load_protected_config = load_system_config
|
||||
PROTECTED_KEYS = SYSTEM_CONFIG_KEYS # For backwards compatibility
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Copyright (C) 2025 James Brotosky, Brandon Wickline
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import datetime
|
||||
import gc
|
||||
import json
|
||||
import logging
|
||||
|
||||
from bson import ObjectId
|
||||
import pandas as pd
|
||||
|
||||
from services.API import AirlockAPIWrapper
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def getExecutions(api: AirlockAPIWrapper, policy, type, days):
|
||||
import airlock_libs
|
||||
|
||||
executionhist_policy = pd.DataFrame()
|
||||
exehist = airlock_libs.pull_policy_exec_histories(api, policy.name, str(type), days)
|
||||
if exehist is not None:
|
||||
data = json.loads(exehist)
|
||||
executionhist_policy = pd.DataFrame(data["response"]["exechistories"])
|
||||
if not executionhist_policy.empty:
|
||||
executionhist_policy = executionhist_policy[
|
||||
[
|
||||
"datetime",
|
||||
"sha256",
|
||||
"publisher",
|
||||
"filename",
|
||||
"hostname",
|
||||
"username",
|
||||
"pprocess",
|
||||
"gprocess",
|
||||
"commandline",
|
||||
]
|
||||
]
|
||||
executionhist_policy["policy"] = policy # Add policy column here
|
||||
executionhist_policy = executionhist_policy.drop_duplicates(
|
||||
subset=["sha256", "filename", "hostname"]
|
||||
)
|
||||
executionhist_policy = executionhist_policy.sort_values(
|
||||
by=["sha256", "filename"]
|
||||
)
|
||||
logger.debug(f"Staging of Execution history for policy: {policy} is complete")
|
||||
|
||||
del data
|
||||
del exehist
|
||||
gc.collect()
|
||||
return executionhist_policy
|
||||
|
||||
|
||||
def skipback(days):
|
||||
"""
|
||||
Generate a MongoDB ObjectId for a given number of days ago from today.
|
||||
"""
|
||||
adjusted_days = days
|
||||
date_days_ago = datetime.datetime.now(datetime.UTC) - datetime.timedelta(
|
||||
days=adjusted_days
|
||||
)
|
||||
timestamp = int(date_days_ago.timestamp())
|
||||
hex_timestamp = format(timestamp, "08x")
|
||||
objectid_hex = hex_timestamp + "0000000000000000"
|
||||
return ObjectId(objectid_hex)
|
||||
@@ -0,0 +1,171 @@
|
||||
# Copyright (C) 2025 James Brotosky, Brandon Wickline
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import base64
|
||||
from getpass import getpass
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import sys
|
||||
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
import keyring
|
||||
|
||||
# Constants
|
||||
KDF_ITERATIONS = 200_000
|
||||
SALT_SIZE = 16 # 128-bit Salt
|
||||
NONCE_SIZE = 12 # AES-GCM
|
||||
KEY_SIZE = 32 # AES-256
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _derive_key(password: bytes, salt: bytes) -> bytes:
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=KEY_SIZE,
|
||||
salt=salt,
|
||||
iterations=KDF_ITERATIONS,
|
||||
)
|
||||
return kdf.derive(password)
|
||||
|
||||
|
||||
def configure_keyring_backend():
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
import keyring.backends.Windows
|
||||
|
||||
keyring.set_keyring(keyring.backends.Windows.WinVaultKeyring())
|
||||
elif system == "Linux":
|
||||
import keyring.backends.kwallet
|
||||
|
||||
keyring.set_keyring(keyring.backends.kwallet.DBusKeyring())
|
||||
else:
|
||||
raise EnvironmentError(f"Unsupported OS: {system}")
|
||||
|
||||
|
||||
def store_api_key(service: str, username: str, api_key: str, password: str):
|
||||
configure_keyring_backend()
|
||||
salt = os.urandom(SALT_SIZE)
|
||||
key = _derive_key(password.encode(), salt)
|
||||
aesgcm = AESGCM(key)
|
||||
nonce = os.urandom(NONCE_SIZE)
|
||||
ct = aesgcm.encrypt(nonce, api_key.encode(), associated_data=None)
|
||||
blob = salt + nonce + ct
|
||||
b64 = base64.b64encode(blob).decode()
|
||||
keyring.set_password(service, username, b64)
|
||||
|
||||
logger.debug(
|
||||
f"API key for service '{service}' and user '{username}' stored successfully."
|
||||
)
|
||||
|
||||
print("\n✅ API key stored securely.")
|
||||
print("The program will now exit. Press Enter to continue...")
|
||||
|
||||
try:
|
||||
_ = input()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_ = None
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def retrieve_api_key(service: str, username: str, password: str) -> str:
|
||||
configure_keyring_backend()
|
||||
b64 = keyring.get_password(service, username)
|
||||
if b64 is None:
|
||||
raise ValueError("No stored secret for this service/username.")
|
||||
blob = base64.b64decode(b64)
|
||||
salt = blob[:SALT_SIZE]
|
||||
nonce = blob[SALT_SIZE : SALT_SIZE + NONCE_SIZE]
|
||||
ct = blob[SALT_SIZE + NONCE_SIZE :]
|
||||
key = _derive_key(password.encode(), salt)
|
||||
aesgcm = AESGCM(key)
|
||||
pt = aesgcm.decrypt(nonce, ct, associated_data=None)
|
||||
return pt.decode()
|
||||
|
||||
|
||||
def api_key_exists(service: str, username: str) -> bool:
|
||||
configure_keyring_backend()
|
||||
return keyring.get_password(service, username) is not None
|
||||
|
||||
|
||||
def check_password_complexity(password: str) -> bool:
|
||||
if len(password) < 12:
|
||||
return False
|
||||
if not re.search(r"[A-Z]", password):
|
||||
return False
|
||||
if not re.search(r"[a-z]", password):
|
||||
return False
|
||||
if not re.search(r"[0-9]", password):
|
||||
return False
|
||||
if not re.search(r"[^A-Za-z0-9]", password):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def getAPI(USERNAME, SERVICE_NAME):
|
||||
logging.debug(
|
||||
f"Checking for stored API key for user '{USERNAME}' in service '{SERVICE_NAME}'..."
|
||||
)
|
||||
|
||||
if api_key_exists(SERVICE_NAME, USERNAME):
|
||||
for attempt in range(1, 4):
|
||||
password = getpass(
|
||||
f"Attempt {attempt}/3 - Enter password to unlock your API key: "
|
||||
)
|
||||
try:
|
||||
apikey = retrieve_api_key(SERVICE_NAME, USERNAME, password)
|
||||
logging.debug("API key successfully retrieved.")
|
||||
return apikey
|
||||
except Exception as e:
|
||||
logging.warning(f"Attempt {attempt} failed: {str(e)}")
|
||||
logging.error("Failed to retrieve API key after 3 incorrect attempts.")
|
||||
raise ValueError("Failed to retrieve API key after 3 incorrect attempts.")
|
||||
else:
|
||||
logging.warning(
|
||||
f"No API key found for user '{USERNAME}' in service '{SERVICE_NAME}'."
|
||||
)
|
||||
api_key = getpass(
|
||||
f"No API key found. Please enter your API key for '{SERVICE_NAME}': "
|
||||
).strip()
|
||||
print(
|
||||
"Please exit and relaunch program after saving your credential to avoid errors"
|
||||
)
|
||||
|
||||
while True:
|
||||
password = getpass("Create a password to encrypt your API key: ")
|
||||
confirm_password = getpass("Confirm your password: ")
|
||||
|
||||
if password != confirm_password:
|
||||
logging.warning("Passwords do not match. Try again.")
|
||||
continue
|
||||
|
||||
if check_password_complexity(password):
|
||||
try:
|
||||
store_api_key(SERVICE_NAME, USERNAME, api_key, password)
|
||||
logging.info("API key stored securely.")
|
||||
break
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to store API key: {e}")
|
||||
break
|
||||
else:
|
||||
logging.warning(
|
||||
"Password does not meet complexity requirements. Try again."
|
||||
)
|
||||
@@ -1,357 +0,0 @@
|
||||
# Copyright (C) 2025 James Brotosky, Brandon Wickline
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import logging
|
||||
from typing import Any, Callable, List, Optional, Union
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from utils.utils import colorText, get_sanitized_input
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Selector:
|
||||
@staticmethod
|
||||
def _get_sorted_items(
|
||||
items: List[Any], label_func: Callable[[Any], str]
|
||||
) -> List[Any]:
|
||||
return sorted(items, key=lambda item: label_func(item).lower())
|
||||
|
||||
@staticmethod
|
||||
def _display_choices(
|
||||
items: List[Any],
|
||||
label_func: Callable[[Any], str],
|
||||
num_columns: int = 3,
|
||||
header: str = "Available Choices:",
|
||||
) -> None:
|
||||
# Force single column if items are DataFrame rows
|
||||
|
||||
if items and isinstance(items[0], (pd.Series, dict)):
|
||||
num_columns = 1
|
||||
|
||||
rows = (len(items) + num_columns - 1) // num_columns
|
||||
print(f"\n{header}")
|
||||
for row in range(rows):
|
||||
line = ""
|
||||
for col in range(num_columns):
|
||||
idx = row + col * rows
|
||||
if idx < len(items):
|
||||
label = label_func(items[idx])
|
||||
line += f"{idx + 1}: {label:<30}"
|
||||
print(line)
|
||||
|
||||
@staticmethod
|
||||
def _display_selected_items(
|
||||
selected: List[Any], label_func: Callable[[Any], str], num_columns: int = 3
|
||||
) -> None:
|
||||
print(colorText("\nCurrent selections:", "cyan"))
|
||||
if not selected:
|
||||
print(" (none)")
|
||||
return
|
||||
sorted_selected = sorted(selected, key=lambda item: label_func(item).lower())
|
||||
rows = (len(sorted_selected) + num_columns - 1) // num_columns
|
||||
for row in range(rows):
|
||||
line = ""
|
||||
for col in range(num_columns):
|
||||
idx = row + col * rows
|
||||
if idx < len(sorted_selected):
|
||||
label = label_func(sorted_selected[idx])
|
||||
line += f"{label:<30}"
|
||||
print(line)
|
||||
|
||||
@staticmethod
|
||||
def _parse_selection_input(input_str: str, max_index: int) -> List[int]:
|
||||
selections = []
|
||||
for part in input_str.split(","):
|
||||
part = part.strip()
|
||||
if "-" in part:
|
||||
try:
|
||||
start, end = map(int, part.split("-"))
|
||||
selections.extend(range(start, end + 1))
|
||||
except ValueError:
|
||||
continue
|
||||
elif part.isdigit():
|
||||
selections.append(int(part))
|
||||
return [i for i in selections if 1 <= i <= max_index]
|
||||
|
||||
@staticmethod
|
||||
def _select_from_list(
|
||||
items: List[Any],
|
||||
label_func: Callable[[Any], str],
|
||||
allow_multiple: bool = False,
|
||||
prompt_each: bool = False,
|
||||
header: str = "Available Choices:",
|
||||
num_columns: int = 3,
|
||||
) -> Union[Optional[Any], List[Any]]:
|
||||
if not items:
|
||||
logger.warning("No items available for selection.")
|
||||
return None
|
||||
|
||||
full_sorted_items = Selector._get_sorted_items(items, label_func)
|
||||
remaining_items = full_sorted_items.copy()
|
||||
selected = []
|
||||
|
||||
if allow_multiple:
|
||||
while True:
|
||||
Selector._display_choices(
|
||||
remaining_items, label_func, num_columns=num_columns, header=header
|
||||
)
|
||||
Selector._display_selected_items(
|
||||
selected, label_func, num_columns=num_columns
|
||||
)
|
||||
choice = (
|
||||
get_sanitized_input(
|
||||
"Select item(s) by number (e.g. 1,3-5), R to reset, Q to finish: "
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
if choice == "q":
|
||||
break
|
||||
elif choice == "r":
|
||||
selected.clear()
|
||||
remaining_items = full_sorted_items.copy()
|
||||
print(colorText("🔄 Selections reset.", "yellow"))
|
||||
continue
|
||||
indices = Selector._parse_selection_input(choice, len(remaining_items))
|
||||
newly_selected = []
|
||||
for index in indices:
|
||||
item = remaining_items[index - 1]
|
||||
if item not in selected:
|
||||
selected.append(item)
|
||||
newly_selected.append(item)
|
||||
if prompt_each:
|
||||
logger.info(f"Selected: {label_func(item)}")
|
||||
else:
|
||||
logger.warning("Item already selected.")
|
||||
remaining_items = [
|
||||
item for item in remaining_items if item not in newly_selected
|
||||
]
|
||||
return selected if selected else None
|
||||
else:
|
||||
Selector._display_choices(
|
||||
full_sorted_items, label_func, num_columns=num_columns, header=header
|
||||
)
|
||||
try:
|
||||
choice = int(get_sanitized_input("Select one item by number: "))
|
||||
if 1 <= choice <= len(full_sorted_items):
|
||||
selected_item = full_sorted_items[choice - 1]
|
||||
logger.info(f"Selected: {label_func(selected_item)}")
|
||||
return selected_item
|
||||
else:
|
||||
logger.warning("Selection out of range.")
|
||||
except ValueError:
|
||||
logger.warning("Invalid input.")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def select_with_mode(
|
||||
items: List[Any],
|
||||
label_func: Callable[[Any], str],
|
||||
header: str = "Available Choices:",
|
||||
) -> List[Any]:
|
||||
print(
|
||||
colorText(
|
||||
"Choose selection mode: [I]nclude only selected, [E]xclude selected, [A]ll (skip):",
|
||||
"white",
|
||||
)
|
||||
)
|
||||
mode = get_sanitized_input("").strip().lower()
|
||||
if mode == "a":
|
||||
return items
|
||||
selected = Selector._select_from_list(
|
||||
items,
|
||||
label_func=label_func,
|
||||
allow_multiple=True,
|
||||
prompt_each=False,
|
||||
header=header,
|
||||
)
|
||||
if not selected:
|
||||
return items
|
||||
if mode == "i":
|
||||
print(colorText(f"✅ Included {len(selected)} item(s).", "green"))
|
||||
return selected
|
||||
elif mode == "e":
|
||||
print(colorText(f"👫 Excluded {len(selected)} item(s).", "yellow"))
|
||||
return [item for item in items if item not in selected]
|
||||
else:
|
||||
print(colorText("⚠️ Invalid mode. Returning all items.", "yellow"))
|
||||
return items
|
||||
|
||||
@staticmethod
|
||||
def select_objects(
|
||||
objects: List[Any], allow_multiple: bool = False, prompt_each: bool = False
|
||||
) -> Union[Optional[Any], List[Any]]:
|
||||
return Selector._select_from_list(
|
||||
objects,
|
||||
label_func=lambda obj: getattr(obj, "name", str(obj)),
|
||||
allow_multiple=allow_multiple,
|
||||
prompt_each=prompt_each,
|
||||
header="Available Objects:",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def select_string(
|
||||
options: List[str], allow_multiple: bool = False, prompt_each: bool = False
|
||||
) -> Union[Optional[str], List[str]]:
|
||||
return Selector._select_from_list(
|
||||
options,
|
||||
label_func=str,
|
||||
allow_multiple=allow_multiple,
|
||||
prompt_each=prompt_each,
|
||||
header="Available Options:",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def select_int(
|
||||
options: List[int], allow_multiple: bool = False, prompt_each: bool = False
|
||||
) -> Union[Optional[int], List[int]]:
|
||||
return Selector._select_from_list(
|
||||
options,
|
||||
label_func=lambda x: str(x),
|
||||
allow_multiple=allow_multiple,
|
||||
prompt_each=prompt_each,
|
||||
header="Available Integers:",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def select_value(
|
||||
prompt: str,
|
||||
value_type: type = int,
|
||||
valid_range: Optional[tuple] = None,
|
||||
allow_quit: bool = False,
|
||||
) -> Optional[Any]:
|
||||
while True:
|
||||
user_input = get_sanitized_input(prompt).strip().lower()
|
||||
if allow_quit and user_input == "q":
|
||||
logger.info("User opted to quit value selection.")
|
||||
return None
|
||||
try:
|
||||
value = value_type(user_input)
|
||||
if valid_range:
|
||||
min_val, max_val = valid_range
|
||||
if not (min_val <= value <= max_val):
|
||||
logger.warning(f"Value out of range ({min_val}–{max_val}).")
|
||||
continue
|
||||
logger.info(f"User selected value: {value}")
|
||||
return value
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid input. Expected a {value_type.__name__}.")
|
||||
|
||||
@staticmethod
|
||||
def confirm(prompt: str = "Are you sure? (Y/N): ") -> bool:
|
||||
while True:
|
||||
response = get_sanitized_input(prompt).strip().lower()
|
||||
if response in ["y", "yes"]:
|
||||
logger.info("User confirmed action.")
|
||||
return True
|
||||
elif response in ["n", "no"]:
|
||||
logger.info("User declined action.")
|
||||
return False
|
||||
else:
|
||||
logger.warning("Invalid confirmation input. Expected 'Y' or 'N'.")
|
||||
|
||||
@staticmethod
|
||||
def select_dataframe_rows(
|
||||
df: pd.DataFrame,
|
||||
columns: Optional[List[str]] = None,
|
||||
allow_multiple: bool = False,
|
||||
prompt_each: bool = False,
|
||||
header: str = "Available Rows:",
|
||||
) -> List[pd.Series]:
|
||||
if df.empty:
|
||||
print("DataFrame is empty.")
|
||||
return []
|
||||
|
||||
if columns:
|
||||
df = df[columns]
|
||||
|
||||
items = [row for _, row in df.iterrows()]
|
||||
|
||||
def label_func(row):
|
||||
return str(row.to_dict())
|
||||
|
||||
result = Selector._select_from_list(
|
||||
items,
|
||||
label_func=label_func,
|
||||
allow_multiple=allow_multiple,
|
||||
prompt_each=prompt_each,
|
||||
header=header,
|
||||
)
|
||||
|
||||
if isinstance(result, pd.Series):
|
||||
return [result]
|
||||
elif isinstance(result, list):
|
||||
return result
|
||||
else:
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def select_dataframe_with_mode(
|
||||
df: pd.DataFrame,
|
||||
columns: Optional[List[str]] = None,
|
||||
header: str = "Available Rows:",
|
||||
) -> List[pd.Series]:
|
||||
if df.empty:
|
||||
print("⚠️ DataFrame is empty.")
|
||||
return []
|
||||
|
||||
# Filter columns if specified
|
||||
if columns:
|
||||
df = df[columns]
|
||||
|
||||
items = df.to_dict("records")
|
||||
|
||||
def label_func(row):
|
||||
return " | ".join(str(row[col]) for col in df.columns)
|
||||
|
||||
# Show rows first
|
||||
print(colorText(header, "cyan"))
|
||||
for i, row in enumerate(items):
|
||||
print(f"{i}: {label_func(row)}")
|
||||
|
||||
# Prompt for mode once
|
||||
print(
|
||||
colorText(
|
||||
"\nChoose selection mode: [I]nclude only selected, [E]xclude selected, [A]ll (skip):",
|
||||
"white",
|
||||
)
|
||||
)
|
||||
mode = get_sanitized_input("").strip().lower()
|
||||
|
||||
if mode == "a":
|
||||
return [pd.Series(row) for row in items]
|
||||
|
||||
# Prompt for selection only once
|
||||
selected = Selector._select_from_list(
|
||||
items,
|
||||
label_func=label_func,
|
||||
allow_multiple=True,
|
||||
prompt_each=False,
|
||||
header=header,
|
||||
)
|
||||
|
||||
if not selected:
|
||||
return [pd.Series(row) for row in items]
|
||||
|
||||
if mode == "i":
|
||||
print(colorText(f"✅ Included {len(selected)} row(s).", "green"))
|
||||
return [pd.Series(row) for row in selected]
|
||||
elif mode == "e":
|
||||
print(colorText(f"👫 Excluded {len(selected)} row(s).", "yellow"))
|
||||
return [pd.Series(row) for row in items if row not in selected]
|
||||
else:
|
||||
print(colorText("⚠️ Invalid mode. Returning no rows.", "yellow"))
|
||||
return []
|
||||
+26
-3
@@ -83,7 +83,18 @@ def get_base_directory() -> Path:
|
||||
return home / ".local" / "share" / "Loxide"
|
||||
|
||||
|
||||
def configure_logging(log_dir: Path, log_level: str = "INFO"):
|
||||
def configure_logging(log_dir: Path, cache_dir: Path, log_level: str = "INFO"):
|
||||
"""
|
||||
Configure logging and return function to attach notification handler.
|
||||
|
||||
Args:
|
||||
log_dir: Directory for log files
|
||||
cache_dir: Directory for cache files (used by version checker)
|
||||
log_level: Logging level string
|
||||
|
||||
Returns:
|
||||
Function to attach notification handler to Textual app
|
||||
"""
|
||||
log_file = log_dir / "Loxide.log"
|
||||
|
||||
config = {
|
||||
@@ -134,7 +145,8 @@ def configure_logging(log_dir: Path, log_level: str = "INFO"):
|
||||
|
||||
# Return a function to attach the notification handler once the app is created
|
||||
def attach_notification_handler(app):
|
||||
"""Attach the Textual notification handler to the root logger."""
|
||||
"""Attach the Textual notification handler and version checker to the app."""
|
||||
# Attach logging handler
|
||||
handler = TextualNotificationHandler(app)
|
||||
handler.setLevel(logging.WARNING) # Only WARNING and above
|
||||
formatter = logging.Formatter("%(name)s: %(message)s")
|
||||
@@ -142,6 +154,17 @@ def configure_logging(log_dir: Path, log_level: str = "INFO"):
|
||||
logging.getLogger().addHandler(handler)
|
||||
logging.getLogger().debug("✅ Textual notification handler attached.")
|
||||
|
||||
# Attach version checker (checks in background, notifies if update available)
|
||||
try:
|
||||
from utils.versionchecker import create_update_notifier
|
||||
|
||||
create_update_notifier(app, cache_dir=cache_dir)
|
||||
logging.getLogger().debug("✅ Version checker attached.")
|
||||
except ImportError as e:
|
||||
logging.getLogger().debug(f"Version checker not available: {e}")
|
||||
except Exception as e:
|
||||
logging.getLogger().warning(f"Could not initialize version checker: {e}")
|
||||
|
||||
return attach_notification_handler
|
||||
|
||||
|
||||
@@ -173,7 +196,7 @@ def setup():
|
||||
|
||||
# Configure logging with system-defined log level
|
||||
log_level = get_system_value("LOG_LEVEL", str, "INFO")
|
||||
attach_handler = configure_logging(dirs["logs"], log_level)
|
||||
attach_handler = configure_logging(dirs["logs"], dirs["cache"], log_level)
|
||||
|
||||
# Load user config (mutable)
|
||||
load_user_config(dirs["config"])
|
||||
|
||||
-437
@@ -19,81 +19,10 @@ import os
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog
|
||||
|
||||
import pandas as pd
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def import_to_dataframe(file_path: str) -> pd.DataFrame:
|
||||
df = pd.DataFrame()
|
||||
|
||||
try:
|
||||
if not os.path.exists(file_path):
|
||||
print(colorText(f"Error: File '{file_path}' does not exist.", "red"))
|
||||
return df
|
||||
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
|
||||
if ext == ".csv":
|
||||
df = pd.read_csv(file_path)
|
||||
elif ext == ".parquet":
|
||||
df = pd.read_parquet(file_path)
|
||||
else:
|
||||
print(colorText(f"Error: Unsupported file extension '{ext}'.", "red"))
|
||||
return df
|
||||
|
||||
if df.empty:
|
||||
print(colorText("Error: File has headers but no data rows.", "red"))
|
||||
else:
|
||||
print(colorText(f"Data loaded successfully from {file_path}", "green"))
|
||||
|
||||
return df
|
||||
|
||||
except pd.errors.EmptyDataError:
|
||||
print(
|
||||
colorText(
|
||||
"Notice: CSV file is completely empty, falling back to empty frame",
|
||||
"white",
|
||||
)
|
||||
)
|
||||
return pd.DataFrame()
|
||||
|
||||
except Exception as e:
|
||||
print(colorText(f"Error reading file: {e}", "red"))
|
||||
return pd.DataFrame()
|
||||
|
||||
|
||||
def choose_directory():
|
||||
root = tk.Tk()
|
||||
root.withdraw() # Hide the main window
|
||||
directory = filedialog.askdirectory(title="Select a Directory")
|
||||
print("Selected directory:", directory)
|
||||
return directory
|
||||
|
||||
|
||||
def choose_file(initial_directory=None, required_substring=None):
|
||||
"""Open a file dialog and ensure the selected file contains a required substring."""
|
||||
while True:
|
||||
root = tk.Tk()
|
||||
root.withdraw() # Hide the main window
|
||||
file_path = filedialog.askopenfilename(initialdir=initial_directory)
|
||||
|
||||
if not file_path:
|
||||
print("No file selected.")
|
||||
return None
|
||||
|
||||
if required_substring and required_substring not in file_path:
|
||||
print(
|
||||
f"The selected file must contain '{required_substring}' in its path or name. Please try again."
|
||||
)
|
||||
else:
|
||||
return file_path
|
||||
|
||||
|
||||
def get_sanitized_input(prompt: str) -> str:
|
||||
while True:
|
||||
user_input = input(prompt)
|
||||
@@ -151,235 +80,6 @@ def irtang():
|
||||
)
|
||||
|
||||
|
||||
def section_header(title):
|
||||
print(
|
||||
colorText(
|
||||
"\n --------------------------------------------------------------------",
|
||||
"cyan",
|
||||
)
|
||||
)
|
||||
print(colorText(f" ------------- {title} -------------", "cyan"))
|
||||
print(
|
||||
colorText(
|
||||
" --------------------------------------------------------------------",
|
||||
"cyan",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def areYouSure():
|
||||
print(
|
||||
colorText(
|
||||
"🛑****************************************************************************************************************************************🛑",
|
||||
"red",
|
||||
)
|
||||
)
|
||||
print(
|
||||
colorText(
|
||||
"⚠️=========================================================================================================================================⚠️",
|
||||
"yellow",
|
||||
)
|
||||
)
|
||||
print(
|
||||
colorText(
|
||||
"🛑========================================================================================================================================🛑",
|
||||
"red",
|
||||
)
|
||||
)
|
||||
print(
|
||||
colorText(
|
||||
"⚠️-------------This program will now begin to make changes to the Airlock Console. Do you understand and agree to proceed? ----------------⚠️",
|
||||
"yellow",
|
||||
)
|
||||
)
|
||||
print(
|
||||
colorText(
|
||||
"🛑========================================================================================================================================🛑",
|
||||
"red",
|
||||
)
|
||||
)
|
||||
print(
|
||||
colorText(
|
||||
"⚠️=========================================================================================================================================⚠️",
|
||||
"yellow",
|
||||
)
|
||||
)
|
||||
print(
|
||||
colorText(
|
||||
"🛑****************************************************************************************************************************************🛑",
|
||||
"red",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def locked():
|
||||
print(
|
||||
colorText(
|
||||
r"""
|
||||
████████████████████████████████████████████████████████████████
|
||||
███ ██
|
||||
██ ██████ ███
|
||||
██ ████████████ ███
|
||||
██ ████ ███ ███
|
||||
██ ███ ███ ███
|
||||
██ ███ ███ ███
|
||||
██ ▒████████████████████ ███
|
||||
██ ██████████████████████ ███
|
||||
██ ██████████████████████ ███
|
||||
██ ██████████████████████ ███
|
||||
██ ██████████████████████ ███
|
||||
██ ██████████████████████ ███
|
||||
██ ███
|
||||
███ ███
|
||||
████████████████████████████████████████████████████████████████████
|
||||
▒██████████████████████████████████████████████████████████████████▒
|
||||
▒████
|
||||
▒████
|
||||
▓██████████████████████████████████████████
|
||||
█████████████████████████████████████████████░
|
||||
""",
|
||||
"yellow",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def printDeviceEnforceChecklist():
|
||||
print(
|
||||
colorText(
|
||||
"\n --------------------------------------------------------------------",
|
||||
"cyan",
|
||||
)
|
||||
)
|
||||
print(
|
||||
colorText(
|
||||
" ------------- 🛠️ 🔒 Prepare to Enforce Policy 🛠️ 🔒 ------------------",
|
||||
"cyan",
|
||||
)
|
||||
)
|
||||
print(
|
||||
colorText(
|
||||
" --------------------------------------------------------------------",
|
||||
"cyan",
|
||||
)
|
||||
)
|
||||
print(
|
||||
colorText(
|
||||
"\nSequentually follow these steps to prepare a policy for enforcement:",
|
||||
"white",
|
||||
)
|
||||
)
|
||||
|
||||
print(
|
||||
colorText(
|
||||
"\n1. Choose which originating policy or policies to move to enforcement",
|
||||
"cyan",
|
||||
)
|
||||
)
|
||||
print(
|
||||
colorText(
|
||||
"2. Pull and stage event history, combine the histories, add hash info, then categorize the hashes",
|
||||
"cyan",
|
||||
)
|
||||
)
|
||||
print(colorText("3. Manually review the files:", "cyan"))
|
||||
print(
|
||||
colorText(
|
||||
" 'needs_approved\\good_{first_policy}_{second_policy}.csv' and 'needs_approved\\unknown_{first_policy}_{second_policy}.csv'",
|
||||
"cyan",
|
||||
)
|
||||
)
|
||||
print(
|
||||
colorText(
|
||||
" Remove the rows containing hashes you do not approve of, and those you would not approve of without metarules.",
|
||||
"cyan",
|
||||
)
|
||||
)
|
||||
print(
|
||||
colorText(
|
||||
" If metarules need to be created, please make note of them, and remove the row from the csv.",
|
||||
"cyan",
|
||||
)
|
||||
)
|
||||
print(
|
||||
colorText(
|
||||
" When complete, save both csv files to the directory 'approved' and choose this option.",
|
||||
"cyan",
|
||||
)
|
||||
)
|
||||
print(
|
||||
colorText(
|
||||
" This will combine these approved hashes with the automatically approved hashes and generate a list of paths to be reviewed",
|
||||
"cyan",
|
||||
)
|
||||
)
|
||||
print(
|
||||
colorText(
|
||||
"4. Manually review the file 'needs_approved\\paths_needing_review.csv'",
|
||||
"cyan",
|
||||
)
|
||||
)
|
||||
print(
|
||||
colorText(
|
||||
" Remove the rows containing path exclusions you do not approve of",
|
||||
"cyan",
|
||||
)
|
||||
)
|
||||
print(
|
||||
colorText(
|
||||
" When complete, save the csv file to the directory 'approved'", "cyan"
|
||||
)
|
||||
)
|
||||
print(
|
||||
colorText(
|
||||
" Do the same process with the list of publishers forthe same directories",
|
||||
"cyan",
|
||||
)
|
||||
)
|
||||
print(colorText(" Preflight Lists will be generated", "cyan"))
|
||||
|
||||
print(
|
||||
colorText(
|
||||
"5. Choose the destination policy and parent and child allow list", "cyan"
|
||||
)
|
||||
)
|
||||
|
||||
print(
|
||||
colorText(
|
||||
"6. Test ------------------------------------------------------", "cyan"
|
||||
)
|
||||
)
|
||||
print(colorText(" Print rather than apply selected data.", "cyan"))
|
||||
|
||||
print(
|
||||
colorText(
|
||||
"7. Liftoff ------------------------------------------------------", "cyan"
|
||||
)
|
||||
)
|
||||
print(
|
||||
colorText(
|
||||
" Apply path exclusions according to allowed and approved paths",
|
||||
"cyan",
|
||||
)
|
||||
)
|
||||
print(
|
||||
colorText(" Apply signed or attested hashes to Parent Allow List", "cyan")
|
||||
)
|
||||
print(
|
||||
colorText(
|
||||
" Apply approved, but unsigned hashes to the Child Allow List", "cyan"
|
||||
)
|
||||
)
|
||||
|
||||
print(
|
||||
colorText(
|
||||
"R. Remove/Reset Generated data - will prompt to allow keeping execution history",
|
||||
"cyan",
|
||||
)
|
||||
)
|
||||
|
||||
print(colorText("B. Back", "cyan"))
|
||||
|
||||
|
||||
def colorText(text, color):
|
||||
colors = {
|
||||
"red": "\033[91m",
|
||||
@@ -394,133 +94,6 @@ def colorText(text, color):
|
||||
return f"{colors.get(color, colors['reset'])}{text}{colors['reset']}"
|
||||
|
||||
|
||||
def formatHTML(df, output_html_path=None, overwrite=True):
|
||||
from datetime import datetime
|
||||
|
||||
# Get current date and filename for subtitle
|
||||
today = datetime.now().strftime("%d %B %Y") # Changed to "Day Month Year"
|
||||
filename = output_html_path.replace(".html", "") if output_html_path else "Report"
|
||||
|
||||
dark_css = """
|
||||
<style>
|
||||
body {
|
||||
background-color: #000000;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
color: #f8f8f2;
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin: 20px auto;
|
||||
padding: 10px;
|
||||
border-bottom: 2px solid #ffd700;
|
||||
max-width: 95%;
|
||||
}
|
||||
.header h1 {
|
||||
color: #ffd700;
|
||||
margin: 0;
|
||||
font-size: 32px;
|
||||
}
|
||||
.header p {
|
||||
color: #00bfff;
|
||||
margin: 5px 0 0 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
.table-container {
|
||||
overflow-y: scroll;
|
||||
margin: 0 auto;
|
||||
width: 95%;
|
||||
max-height: calc(80vh - 100px);
|
||||
display: block;
|
||||
border: 1px solid #3a3a4d;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
background-color: #1e1e2f;
|
||||
color: #f8f8f2;
|
||||
width: max-content;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #3a3a4d;
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
max-width: 300px;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
/* First column: no wrap */
|
||||
td:nth-child(1), th:nth-child(1) {
|
||||
white-space: nowrap;
|
||||
max-width: none !important;
|
||||
word-wrap: normal !important;
|
||||
}
|
||||
th {
|
||||
background-color: #2e2e40;
|
||||
color: #ffd700;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
tr:nth-child(even) {
|
||||
background-color: #262638;
|
||||
}
|
||||
tr:hover {
|
||||
background-color: #33334d;
|
||||
color: #00bfff;
|
||||
}
|
||||
/* Custom scrollbar styling */
|
||||
.table-container::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
}
|
||||
.table-container::-webkit-scrollbar-track {
|
||||
background: #1e1e2f;
|
||||
}
|
||||
.table-container::-webkit-scrollbar-thumb {
|
||||
background-color: #3a3a4d;
|
||||
border-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
"""
|
||||
|
||||
header = f"""
|
||||
<div class="header">
|
||||
<h1>Airlock Tools</h1>
|
||||
<p>{filename} - {today}</p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
html_table = df.to_html(index=False, escape=False)
|
||||
styled_html = (
|
||||
f"<html>\n"
|
||||
f"<head><title>Airlock Tools Report</title></head>\n"
|
||||
f"<body>\n"
|
||||
f"{dark_css}\n"
|
||||
f"{header}\n"
|
||||
f"<div class='table-container'>\n"
|
||||
f" {html_table}\n"
|
||||
f"</div>\n"
|
||||
f"</body>\n"
|
||||
f"</html>"
|
||||
)
|
||||
if output_html_path:
|
||||
with open(output_html_path, "w", encoding="utf-8") as f:
|
||||
f.write(styled_html)
|
||||
print(f"✅ Styled table saved to '{output_html_path}'")
|
||||
elif overwrite:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=".html", delete=False, mode="w", encoding="utf-8"
|
||||
) as f:
|
||||
f.write(styled_html)
|
||||
temp_path = f.name
|
||||
|
||||
print(f"✅ Styled table saved to temporary file: {temp_path}")
|
||||
else:
|
||||
return styled_html
|
||||
|
||||
|
||||
def open_directory(path):
|
||||
system = platform.system()
|
||||
|
||||
@@ -530,13 +103,3 @@ def open_directory(path):
|
||||
subprocess.run(["xdg-open", path])
|
||||
else:
|
||||
raise OSError(f"Unsupported operating system: {system}")
|
||||
|
||||
|
||||
def print_x_wide(items: list, width: int):
|
||||
for i in range(0, len(items), width):
|
||||
row = items[i : i + width]
|
||||
print(" | ".join(row))
|
||||
|
||||
|
||||
def clear_screen():
|
||||
os.system("cls" if os.name == "nt" else "clear")
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
# Copyright (C) 2025 James Brotosky, Brandon Wickline
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published
|
||||
# by the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
Version checking and update notification system for Loxide.
|
||||
|
||||
Checks against Gitea releases at:
|
||||
https://git.racooncity.org/brotoskyj/AirlockTools/releases
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
import threading
|
||||
from typing import Callable, Optional
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Current application version - UPDATE THIS ON EACH RELEASE
|
||||
__version__ = "0.7.0"
|
||||
|
||||
# Gitea release API configuration
|
||||
GITEA_API_BASE = "https://git.racooncity.org/api/v1"
|
||||
REPO_OWNER = "brotoskyj"
|
||||
REPO_NAME = "AirlockTools"
|
||||
RELEASES_URL = f"{GITEA_API_BASE}/repos/{REPO_OWNER}/{REPO_NAME}/releases"
|
||||
RELEASES_PAGE_URL = f"https://git.racooncity.org/{REPO_OWNER}/{REPO_NAME}/releases"
|
||||
|
||||
# How often to check for updates (in hours)
|
||||
CHECK_INTERVAL_HOURS = 24
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReleaseInfo:
|
||||
"""Information about a release."""
|
||||
|
||||
tag_name: str
|
||||
version: tuple # Parsed semantic version (major, minor, patch)
|
||||
name: str
|
||||
body: str # Release notes
|
||||
published_at: datetime
|
||||
html_url: str
|
||||
download_url: Optional[str] = None # URL to download the release asset
|
||||
is_prerelease: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpdateCheckResult:
|
||||
"""Result of an update check."""
|
||||
|
||||
current_version: str
|
||||
latest_version: Optional[str]
|
||||
update_available: bool
|
||||
release_info: Optional[ReleaseInfo]
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
def parse_version(version_str: str) -> Optional[tuple]:
|
||||
"""
|
||||
Parse a version string into a comparable tuple.
|
||||
Supports formats: v1.2.3, 1.2.3, v1.2, 1.2
|
||||
|
||||
Returns (major, minor, patch) tuple or None if parsing fails.
|
||||
"""
|
||||
if not version_str:
|
||||
return None
|
||||
|
||||
# Strip 'v' prefix if present
|
||||
clean = version_str.lstrip("vV").strip()
|
||||
|
||||
# Match semantic version pattern
|
||||
match = re.match(r"^(\d+)(?:\.(\d+))?(?:\.(\d+))?", clean)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
major = int(match.group(1))
|
||||
minor = int(match.group(2)) if match.group(2) else 0
|
||||
patch = int(match.group(3)) if match.group(3) else 0
|
||||
|
||||
return (major, minor, patch)
|
||||
|
||||
|
||||
def compare_versions(v1: tuple, v2: tuple) -> int:
|
||||
"""
|
||||
Compare two version tuples.
|
||||
|
||||
Returns:
|
||||
-1 if v1 < v2
|
||||
0 if v1 == v2
|
||||
1 if v1 > v2
|
||||
"""
|
||||
for a, b in zip(v1, v2):
|
||||
if a < b:
|
||||
return -1
|
||||
if a > b:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def get_current_version() -> str:
|
||||
"""Get the current application version."""
|
||||
return __version__
|
||||
|
||||
|
||||
def _parse_release_response(release_data: dict) -> Optional[ReleaseInfo]:
|
||||
"""Parse a release from Gitea API response."""
|
||||
try:
|
||||
tag_name = release_data.get("tag_name", "")
|
||||
version = parse_version(tag_name)
|
||||
if not version:
|
||||
logger.debug(f"Could not parse version from tag: {tag_name}")
|
||||
return None
|
||||
|
||||
# Parse published date
|
||||
published_str = release_data.get("published_at", "")
|
||||
try:
|
||||
published_at = datetime.fromisoformat(published_str.replace("Z", "+00:00"))
|
||||
except (ValueError, AttributeError):
|
||||
published_at = datetime.now(UTC)
|
||||
|
||||
# Get download URL from assets if available
|
||||
download_url = None
|
||||
assets = release_data.get("assets", [])
|
||||
for asset in assets:
|
||||
# Prefer .exe or .zip files
|
||||
name = asset.get("name", "").lower()
|
||||
if name.endswith((".exe", ".zip", ".msi")):
|
||||
download_url = asset.get("browser_download_url")
|
||||
break
|
||||
|
||||
return ReleaseInfo(
|
||||
tag_name=tag_name,
|
||||
version=version,
|
||||
name=release_data.get("name", tag_name),
|
||||
body=release_data.get("body", ""),
|
||||
published_at=published_at,
|
||||
html_url=release_data.get("html_url", RELEASES_PAGE_URL),
|
||||
download_url=download_url,
|
||||
is_prerelease=release_data.get("prerelease", False),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse release data: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def fetch_latest_release(
|
||||
include_prerelease: bool = False, timeout: int = 10
|
||||
) -> Optional[ReleaseInfo]:
|
||||
"""
|
||||
Fetch the latest release from Gitea.
|
||||
|
||||
Args:
|
||||
include_prerelease: Whether to include pre-release versions
|
||||
timeout: Request timeout in seconds
|
||||
|
||||
Returns:
|
||||
ReleaseInfo for the latest release, or None if fetch fails
|
||||
"""
|
||||
try:
|
||||
response = requests.get(
|
||||
RELEASES_URL,
|
||||
params={"limit": 10}, # Get last 10 releases to find latest stable
|
||||
timeout=timeout,
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
releases = response.json()
|
||||
if not releases:
|
||||
logger.debug("No releases found")
|
||||
return None
|
||||
|
||||
# Find the latest release (first non-prerelease if we're excluding them)
|
||||
for release_data in releases:
|
||||
release_info = _parse_release_response(release_data)
|
||||
if release_info is None:
|
||||
continue
|
||||
|
||||
if include_prerelease or not release_info.is_prerelease:
|
||||
return release_info
|
||||
|
||||
# If all are prereleases and we're excluding them, return the first one anyway
|
||||
# but log a warning
|
||||
if releases:
|
||||
logger.debug("All releases are pre-releases")
|
||||
return _parse_release_response(releases[0])
|
||||
|
||||
return None
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning("Timeout fetching releases from Gitea")
|
||||
return None
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(f"Failed to fetch releases: {e}")
|
||||
return None
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
logger.warning(f"Failed to parse release response: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def check_for_updates(include_prerelease: bool = False) -> UpdateCheckResult:
|
||||
"""
|
||||
Check if a newer version is available.
|
||||
|
||||
Args:
|
||||
include_prerelease: Whether to consider pre-release versions
|
||||
|
||||
Returns:
|
||||
UpdateCheckResult with the check results
|
||||
"""
|
||||
current = get_current_version()
|
||||
current_parsed = parse_version(current)
|
||||
|
||||
if not current_parsed:
|
||||
return UpdateCheckResult(
|
||||
current_version=current,
|
||||
latest_version=None,
|
||||
update_available=False,
|
||||
release_info=None,
|
||||
error="Could not parse current version",
|
||||
)
|
||||
|
||||
release_info = fetch_latest_release(include_prerelease=include_prerelease)
|
||||
|
||||
if release_info is None:
|
||||
return UpdateCheckResult(
|
||||
current_version=current,
|
||||
latest_version=None,
|
||||
update_available=False,
|
||||
release_info=None,
|
||||
error="Could not fetch release information",
|
||||
)
|
||||
|
||||
is_newer = compare_versions(release_info.version, current_parsed) > 0
|
||||
|
||||
return UpdateCheckResult(
|
||||
current_version=current,
|
||||
latest_version=release_info.tag_name,
|
||||
update_available=is_newer,
|
||||
release_info=release_info,
|
||||
)
|
||||
|
||||
|
||||
class VersionChecker:
|
||||
"""
|
||||
Background version checker that periodically checks for updates
|
||||
and can notify the application when updates are available.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cache_dir: Optional[Path] = None,
|
||||
check_interval_hours: int = CHECK_INTERVAL_HOURS,
|
||||
on_update_available: Optional[Callable[[UpdateCheckResult], None]] = None,
|
||||
):
|
||||
"""
|
||||
Initialize the version checker.
|
||||
|
||||
Args:
|
||||
cache_dir: Directory to store last check timestamp
|
||||
check_interval_hours: Hours between automatic checks
|
||||
on_update_available: Callback when update is available
|
||||
"""
|
||||
self.cache_dir = cache_dir
|
||||
self.check_interval = timedelta(hours=check_interval_hours)
|
||||
self.on_update_available = on_update_available
|
||||
self._last_check: Optional[datetime] = None
|
||||
self._last_result: Optional[UpdateCheckResult] = None
|
||||
self._check_thread: Optional[threading.Thread] = None
|
||||
self._dismissed_version: Optional[str] = None
|
||||
|
||||
# Load cached state
|
||||
self._load_cache()
|
||||
|
||||
@property
|
||||
def cache_file(self) -> Optional[Path]:
|
||||
if self.cache_dir:
|
||||
return self.cache_dir / "version_check_cache.json"
|
||||
return None
|
||||
|
||||
def _load_cache(self) -> None:
|
||||
"""Load cached check state."""
|
||||
if not self.cache_file or not self.cache_file.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.cache_file, "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
if "last_check" in data:
|
||||
self._last_check = datetime.fromisoformat(data["last_check"])
|
||||
# Don't load dismissed_version - dismiss is session-only
|
||||
|
||||
except (json.JSONDecodeError, ValueError, OSError) as e:
|
||||
logger.debug(f"Could not load version check cache: {e}")
|
||||
|
||||
def _save_cache(self) -> None:
|
||||
"""Save check state to cache."""
|
||||
if not self.cache_file:
|
||||
return
|
||||
|
||||
try:
|
||||
self.cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = {}
|
||||
if self._last_check:
|
||||
data["last_check"] = self._last_check.isoformat()
|
||||
# Don't save dismissed_version - dismiss is session-only
|
||||
|
||||
with open(self.cache_file, "w") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
except OSError as e:
|
||||
logger.debug(f"Could not save version check cache: {e}")
|
||||
|
||||
def should_check(self) -> bool:
|
||||
"""Determine if enough time has passed to check again."""
|
||||
if self._last_check is None:
|
||||
return True
|
||||
|
||||
elapsed = datetime.now(UTC) - self._last_check
|
||||
return elapsed >= self.check_interval
|
||||
|
||||
def check_now(
|
||||
self, force: bool = False, include_prerelease: bool = False
|
||||
) -> UpdateCheckResult:
|
||||
"""
|
||||
Check for updates immediately.
|
||||
|
||||
Args:
|
||||
force: Check even if recently checked
|
||||
include_prerelease: Include pre-release versions
|
||||
|
||||
Returns:
|
||||
UpdateCheckResult
|
||||
"""
|
||||
if not force and not self.should_check() and self._last_result:
|
||||
return self._last_result
|
||||
|
||||
result = check_for_updates(include_prerelease=include_prerelease)
|
||||
self._last_check = datetime.now(UTC)
|
||||
self._last_result = result
|
||||
self._save_cache()
|
||||
|
||||
# Notify if update available and not dismissed
|
||||
if (
|
||||
result.update_available
|
||||
and self.on_update_available
|
||||
and result.latest_version != self._dismissed_version
|
||||
):
|
||||
self.on_update_available(result)
|
||||
|
||||
return result
|
||||
|
||||
def check_async(
|
||||
self, force: bool = False, include_prerelease: bool = False
|
||||
) -> None:
|
||||
"""
|
||||
Check for updates in background thread.
|
||||
|
||||
Args:
|
||||
force: Check even if recently checked
|
||||
include_prerelease: Include pre-release versions
|
||||
"""
|
||||
if self._check_thread and self._check_thread.is_alive():
|
||||
return # Already checking
|
||||
|
||||
if not force and not self.should_check():
|
||||
return # Too soon to check again
|
||||
|
||||
def _check():
|
||||
try:
|
||||
self.check_now(force=True, include_prerelease=include_prerelease)
|
||||
except Exception as e:
|
||||
logger.debug(f"Background version check failed: {e}")
|
||||
|
||||
self._check_thread = threading.Thread(target=_check, daemon=True)
|
||||
self._check_thread.start()
|
||||
|
||||
def dismiss_update(self, version: str) -> None:
|
||||
"""
|
||||
Dismiss update notification for a specific version.
|
||||
Only lasts for the current session - will nag again on next startup.
|
||||
|
||||
Args:
|
||||
version: Version to dismiss (e.g., "v1.2.3")
|
||||
"""
|
||||
# Session-only dismiss - don't save to cache
|
||||
self._dismissed_version = version
|
||||
|
||||
def clear_dismissed(self) -> None:
|
||||
"""Clear the dismissed version so user will be nagged again."""
|
||||
self._dismissed_version = None
|
||||
|
||||
def get_last_result(self) -> Optional[UpdateCheckResult]:
|
||||
"""Get the result of the last check."""
|
||||
return self._last_result
|
||||
|
||||
|
||||
# Global instance for easy access
|
||||
_global_checker: Optional[VersionChecker] = None
|
||||
|
||||
|
||||
def get_version_checker(
|
||||
cache_dir: Optional[Path] = None,
|
||||
on_update_available: Optional[Callable[[UpdateCheckResult], None]] = None,
|
||||
) -> VersionChecker:
|
||||
"""
|
||||
Get or create the global version checker instance.
|
||||
|
||||
Args:
|
||||
cache_dir: Directory for caching (only used on first call)
|
||||
on_update_available: Callback for updates (only used on first call)
|
||||
|
||||
Returns:
|
||||
The global VersionChecker instance
|
||||
"""
|
||||
global _global_checker
|
||||
|
||||
if _global_checker is None:
|
||||
_global_checker = VersionChecker(
|
||||
cache_dir=cache_dir,
|
||||
on_update_available=on_update_available,
|
||||
)
|
||||
|
||||
return _global_checker
|
||||
|
||||
|
||||
def format_update_message(result: UpdateCheckResult, short: bool = False) -> str:
|
||||
"""
|
||||
Format a human-readable update message.
|
||||
|
||||
Args:
|
||||
result: The update check result
|
||||
short: Whether to use a short format
|
||||
|
||||
Returns:
|
||||
Formatted message string
|
||||
"""
|
||||
if not result.update_available:
|
||||
return f"✅ Loxide is up to date (v{result.current_version})"
|
||||
|
||||
if short:
|
||||
return f"🆕 Update available: {result.latest_version}"
|
||||
|
||||
msg = f"🆕 Loxide {result.latest_version} is available! (current: v{result.current_version})"
|
||||
|
||||
if result.release_info:
|
||||
msg += f"\n📥 Download: {result.release_info.html_url}"
|
||||
|
||||
# Include release notes preview if available
|
||||
if result.release_info.body:
|
||||
notes = result.release_info.body.strip()
|
||||
# Truncate if too long
|
||||
if len(notes) > 200:
|
||||
notes = notes[:200] + "..."
|
||||
msg += f"\n\n📋 Release Notes:\n{notes}"
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Textual TUI Integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def create_update_notifier(
|
||||
app, cache_dir: Optional[Path] = None, nag_on_startup: bool = True
|
||||
):
|
||||
"""
|
||||
Create a version checker that notifies via Textual toast notifications.
|
||||
|
||||
This should be called after the Textual app is created.
|
||||
|
||||
Args:
|
||||
app: The Textual App instance
|
||||
cache_dir: Directory for caching check state
|
||||
nag_on_startup: Always show notification on startup if update available
|
||||
|
||||
Returns:
|
||||
The VersionChecker instance
|
||||
"""
|
||||
|
||||
def on_update_available(result: UpdateCheckResult):
|
||||
"""Callback when update is available - show toast notification."""
|
||||
try:
|
||||
msg = f"🆕 Update available: {result.latest_version}\nGo to Settings to download"
|
||||
try:
|
||||
app.notify(
|
||||
msg, title="Loxide Update Available", severity="warning", timeout=15
|
||||
)
|
||||
except RuntimeError:
|
||||
app.call_from_thread(
|
||||
app.notify,
|
||||
msg,
|
||||
title="Loxide Update Available",
|
||||
severity="warning",
|
||||
timeout=15,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not show update notification: {e}")
|
||||
|
||||
checker = get_version_checker(
|
||||
cache_dir=cache_dir,
|
||||
on_update_available=on_update_available,
|
||||
)
|
||||
|
||||
# Store checker on app so Loxide.on_mount can use it
|
||||
if nag_on_startup:
|
||||
app._version_checker = checker
|
||||
app._version_nag_shown = False
|
||||
|
||||
return checker
|
||||
|
||||
|
||||
def check_for_updates_startup(
|
||||
cache_dir: Optional[Path] = None,
|
||||
) -> Optional[UpdateCheckResult]:
|
||||
"""
|
||||
Check for updates during application startup.
|
||||
|
||||
This performs a synchronous check but respects the cache interval,
|
||||
so it will only actually query the network once per CHECK_INTERVAL_HOURS.
|
||||
|
||||
Returns the result if an update is available, None otherwise.
|
||||
|
||||
Example usage:
|
||||
result = check_for_updates_startup(cache_dir)
|
||||
if result and result.update_available:
|
||||
print(format_update_message(result))
|
||||
"""
|
||||
checker = get_version_checker(cache_dir=cache_dir)
|
||||
|
||||
# Only check if enough time has passed (uses cache)
|
||||
if not checker.should_check():
|
||||
result = checker.get_last_result()
|
||||
if result and result.update_available:
|
||||
return result
|
||||
return None
|
||||
|
||||
result = checker.check_now(force=False)
|
||||
if result.update_available:
|
||||
return result
|
||||
return None
|
||||
Reference in New Issue
Block a user