VOC_Monitor/app/updater/update_manager.py

850 lines
31 KiB
Python

"""
업데이트 관리자
메인 프로그램에서 사용하는 업데이트 관리 모듈입니다.
Supabase에서 버전 정보를 조회하고 업데이트를 준비합니다.
작성자: KH.Choi
최종 수정: 2026-02-18
"""
import json
import logging
import copy
import shutil
import subprocess
import sys
import tempfile
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Optional
import requests
from .__version__ import PROGRAM_ID, VERSION
logger = logging.getLogger(__name__)
UPDATER_CONFIG_FILENAME = "config.json"
# ============================================================================
# 데이터 클래스
# ============================================================================
@dataclass
class VersionInfo:
"""
Supabase에서 조회한 버전 정보
Attributes:
version: 버전 번호
is_stable: 안정판 여부
release_note: 배포 노트
download_url: 다운로드 URL
min_required_version: 최소 요구 버전
"""
version: str
is_stable: bool
release_note: Optional[str] = None
download_url: Optional[str] = None
min_required_version: Optional[str] = None
update_level: str = "patch"
@dataclass
class UpdateConfig:
"""
updater.exe로 전달되는 구성 정보
Attributes:
download_url: 다운로드 URL (zip 파일)
target_path: 설치 대상 경로
version: 업데이트할 버전
restart_exe: 업데이트 후 실행할 실행파일명
"""
download_url: str
target_path: str
version: str
restart_exe: str = "voc_noti.exe"
update_level: str = "patch"
preserve_globs: list[str] | None = None
cleanup_backup_on_success: bool = True
clean_install_before_copy: bool = False
# ============================================================================
# 예외 클래스
# ============================================================================
class UpdateError(Exception):
"""업데이트 관련 기본 예외"""
def __init__(self, message: str, code: str = "ERR_UPDATE_UNKNOWN"):
self.message = message
self.code = code
super().__init__(message)
class NetworkError(UpdateError):
"""네트워크 연결 실패"""
def __init__(self, message: str, code: str = "ERR_NETWORK"):
super().__init__(message, code)
class ConfigError(UpdateError):
"""설정 파일 오류"""
def __init__(self, message: str, code: str = "ERR_CONFIG"):
super().__init__(message, code)
def _parse_ssl_verify(value: Any, default: bool = True) -> bool:
"""ssl_verify 값을 bool로 정규화"""
if isinstance(value, bool):
return value
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in {"true", "1", "yes", "on"}:
return True
if lowered in {"false", "0", "no", "off"}:
return False
if isinstance(value, (int, float)):
return bool(value)
return default
def _build_requests_verify_option(ssl_verify: bool, ca_bundle_path: str) -> bool | str:
"""requests.verify 옵션 생성 (bool 또는 CA 번들 경로)"""
if not ssl_verify:
return False
bundle_path = str(ca_bundle_path or "").strip()
if not bundle_path:
return True
bundle = Path(bundle_path).expanduser()
if bundle.exists():
return str(bundle)
logger.warning(f"CA 번들 경로를 찾을 수 없습니다. 기본 인증서 검증 사용: {bundle}")
return True
def _strip_json_comments(content: str) -> str:
"""JSON 문자열의 주석(//, /* */) 제거"""
result = []
i = 0
in_string = False
escape_next = False
while i < len(content):
char = content[i]
if escape_next:
result.append(char)
escape_next = False
i += 1
continue
if char == "\\" and in_string:
result.append(char)
escape_next = True
i += 1
continue
if char == '"':
in_string = not in_string
result.append(char)
i += 1
continue
if not in_string:
if content[i : i + 2] == "//":
while i < len(content) and content[i] != "\n":
i += 1
continue
if content[i : i + 2] == "/*":
i += 2
while i < len(content) - 1 and content[i : i + 2] != "*/":
i += 1
i += 2
continue
result.append(char)
i += 1
return "".join(result)
def _select_environment_config(
environments: dict[str, dict[str, Any]],
requested_environment: Optional[str],
default_environment: str,
) -> tuple[str, dict[str, Any]]:
"""환경 설정 선택 (요청 환경 > 기본 환경 > 우선순위)"""
target = requested_environment or default_environment
if target in environments and environments[target].get("enabled", True):
return target, environments[target]
enabled_items = [(k, v) for k, v in environments.items() if v.get("enabled", True)]
if not enabled_items:
return "", {}
enabled_items.sort(key=lambda item: item[1].get("priority", 999))
return enabled_items[0]
def load_updater_connection_config(
config_path: Optional[Path] = None,
environment: Optional[str] = None,
timeout: int = 10,
) -> dict[str, Any]:
"""
업데이터 연결 설정 로드
우선순위:
1) 원격 config_url (성공 시)
2) 로컬 fallback
"""
path = config_path or (Path(__file__).resolve().parent / UPDATER_CONFIG_FILENAME)
if not path.exists():
raise ConfigError(f"업데이터 설정 파일이 없습니다: {path}")
try:
local_raw = _strip_json_comments(path.read_text(encoding="utf-8"))
local_config = json.loads(local_raw)
except (OSError, json.JSONDecodeError) as e:
raise ConfigError(f"업데이터 설정 파일 파싱 실패: {e}") from e
fallback = local_config.get("fallback", {})
default_env = local_config.get("default_environment", "main")
config_url = local_config.get("config_url", "").strip()
local_ssl_verify = _parse_ssl_verify(local_config.get("ssl_verify", True), default=True)
local_ca_bundle = str(local_config.get("ca_bundle_path", "")).strip()
# 1) 원격 설정 시도
if config_url:
try:
headers = {
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0",
}
response = requests.get(
config_url,
timeout=timeout,
headers=headers,
verify=_build_requests_verify_option(local_ssl_verify, local_ca_bundle),
)
response.raise_for_status()
remote_raw = _strip_json_comments(response.text)
remote = json.loads(remote_raw)
envs = remote.get("environments", {})
if isinstance(envs, dict) and envs:
env_name, env_config = _select_environment_config(
environments=envs,
requested_environment=environment,
default_environment=remote.get("defaultEnvironment", default_env),
)
if env_config:
url = env_config.get("supabaseUrl", "").strip()
key = env_config.get("anonKey", "").strip()
if url and key:
env_ssl_verify = _parse_ssl_verify(
env_config.get("sslVerify", local_ssl_verify), default=local_ssl_verify
)
env_ca_bundle = str(env_config.get("caBundlePath", local_ca_bundle)).strip()
return {
"supabase_url": url,
"supabase_key": key,
"ssl_verify": env_ssl_verify,
"ca_bundle_path": env_ca_bundle,
"environment": env_name,
"source": "remote",
}
except Exception as e:
logger.warning(f"원격 연결 설정 로드 실패, fallback 사용: {e}")
# 2) 로컬 fallback
url = str(fallback.get("supabase_url", "")).strip()
key = str(fallback.get("anon_key", "")).strip()
fallback_ssl_verify = _parse_ssl_verify(
fallback.get("ssl_verify", local_ssl_verify), default=local_ssl_verify
)
fallback_ca_bundle = str(fallback.get("ca_bundle_path", local_ca_bundle)).strip()
env = environment or default_env
if not url or not key:
raise ConfigError("fallback 설정에 supabase_url 또는 anon_key가 없습니다.")
return {
"supabase_url": url,
"supabase_key": key,
"ssl_verify": fallback_ssl_verify,
"ca_bundle_path": fallback_ca_bundle,
"environment": env,
"source": "fallback",
}
def _resolve_connection_config_path(raw_path: str) -> Path:
"""연결 설정 파일 경로 해석 (상대경로/패킹 환경 지원)"""
path = Path(raw_path)
if path.is_absolute():
return path
# 1) 현재 작업 디렉토리 기준
cwd_candidate = (Path.cwd() / path).resolve()
if cwd_candidate.exists():
return cwd_candidate
# 2) 실행 파일 기준 (패킹 환경)
if getattr(sys, "frozen", False):
exe_candidate = (Path(sys.executable).parent / path).resolve()
if exe_candidate.exists():
return exe_candidate
# 3) 모듈 기준 (개발 환경)
module_candidate = (Path(__file__).resolve().parents[2] / path).resolve()
return module_candidate
def _load_local_update_policy(config_path: Optional[Path]) -> dict[str, Any]:
"""updater/config.json에서 update_policy를 로드합니다."""
if not config_path or not config_path.exists():
return {}
try:
raw = _strip_json_comments(config_path.read_text(encoding="utf-8"))
config = json.loads(raw)
policy = config.get("update_policy", {})
if isinstance(policy, dict):
return policy
except Exception as e:
logger.warning(f"update_policy 로드 실패, 기본값 사용: {e}")
return {}
def _normalize_update_level(value: Any) -> str:
"""update_level 정규화 (patch/minor/major)"""
level = str(value or "patch").strip().lower()
if level not in {"patch", "minor", "major"}:
return "patch"
return level
def _normalize_level_list(values: Any, default: list[str]) -> list[str]:
"""레벨 목록 정규화"""
if not isinstance(values, list):
values = default
normalized = []
for value in values:
level = _normalize_update_level(value)
if level not in normalized:
normalized.append(level)
if not normalized:
normalized = default
return normalized
# ============================================================================
# 버전 비교 유틸리티
# ============================================================================
def compare_versions(v1: str, v2: str) -> int:
"""
버전 비교
Args:
v1: 첫 번째 버전 (예: "3.2.0")
v2: 두 번째 버전 (예: "3.1.0")
Returns:
-1: v1 < v2
0: v1 == v2
1: v1 > v2
"""
try:
parts1 = [int(x) for x in v1.split('.')]
parts2 = [int(x) for x in v2.split('.')]
# 길이 맞추기
max_len = max(len(parts1), len(parts2))
parts1.extend([0] * (max_len - len(parts1)))
parts2.extend([0] * (max_len - len(parts2)))
for p1, p2 in zip(parts1, parts2):
if p1 < p2:
return -1
elif p1 > p2:
return 1
return 0
except (ValueError, AttributeError):
logger.warning(f"버전 비교 실패: {v1} vs {v2}")
return 0
# ============================================================================
# UpdateManager 클래스
# ============================================================================
class UpdateManager:
"""
자동 업데이트 관리자
Supabase에서 버전 정보를 조회하고 업데이트를 처리합니다.
Attributes:
program_id: 프로그램 식별자
current_version: 현재 버전
supabase_url: Supabase 프로젝트 URL
supabase_key: Supabase API 키
check_interval: 업데이트 체크 간격 (시간)
사용 예시:
updater = UpdateManager(
supabase_url="https://xxx.supabase.co",
supabase_key="xxx"
)
# 업데이트 확인
version_info = updater.check_for_updates()
if version_info:
# 업데이트 준비 및 실행
updater.prepare_update(version_info)
updater.launch_updater()
"""
CONFIG_FILENAME = "voc_updater_config.json"
UPDATER_EXE_NAME = "updater.exe"
def __init__(
self,
supabase_url: str,
supabase_key: str,
program_id: str = PROGRAM_ID,
current_version: str = VERSION,
check_interval: int = 1,
version_table: str = "program_version",
ssl_verify: bool = True,
ca_bundle_path: str = "",
preserve_globs: Optional[list[str]] = None,
cleanup_backup_on_success: bool = True,
clean_install_before_copy: bool = False,
allowed_update_levels: Optional[list[str]] = None,
clean_install_levels: Optional[list[str]] = None,
):
"""
UpdateManager 초기화
Args:
supabase_url: Supabase 프로젝트 URL
supabase_key: Supabase API 키 (anon key)
program_id: 프로그램 식별자
current_version: 현재 버전
check_interval: 업데이트 체크 간격 (시간)
"""
self.program_id = program_id
self.current_version = current_version
self.supabase_url = supabase_url.rstrip('/')
self.supabase_key = supabase_key
self.check_interval = check_interval
self.version_table = version_table
self.ssl_verify = ssl_verify
self.ca_bundle_path = ca_bundle_path
self.preserve_globs = list(preserve_globs or ["data/*.sqlite", "data/*.json", "data/*.db"])
self.cleanup_backup_on_success = cleanup_backup_on_success
self.clean_install_before_copy = clean_install_before_copy
self.allowed_update_levels = _normalize_level_list(allowed_update_levels, ["patch", "minor", "major"])
self.clean_install_levels = _normalize_level_list(clean_install_levels, ["minor", "major"])
self._latest_version: Optional[VersionInfo] = None
self._stop_flag = threading.Event()
self.last_error_code = ""
self.last_error_message = ""
def _set_last_error(self, code: str, message: str) -> None:
self.last_error_code = code
self.last_error_message = message
def _clear_last_error(self) -> None:
self.last_error_code = ""
self.last_error_message = ""
@property
def config_path(self) -> Path:
"""업데이트 설정 파일 경로"""
return Path(tempfile.gettempdir()) / self.CONFIG_FILENAME
@property
def temp_updater_path(self) -> Path:
"""임시 updater.exe 경로"""
return Path(tempfile.gettempdir()) / self.UPDATER_EXE_NAME
@property
def install_dir(self) -> Path:
"""메인 프로그램 설치 경로"""
if getattr(sys, "frozen", False):
return Path(sys.executable).parent
return Path(__file__).resolve().parents[2]
@staticmethod
def _get_creation_flags() -> int:
"""운영체제별 프로세스 생성 플래그"""
if sys.platform.startswith("win"):
return subprocess.CREATE_NEW_PROCESS_GROUP
return 0
def check_for_updates(self) -> Optional[VersionInfo]:
"""
Supabase에서 최신 버전 확인
Returns:
VersionInfo: 업데이트 가능한 경우 버전 정보
None: 업데이트 없음
"""
try:
self._clear_last_error()
if not self.supabase_url:
raise ConfigError("Supabase URL이 비어있습니다.", code="ERR_SUPABASE_URL_EMPTY")
if not self.supabase_key:
raise ConfigError("Supabase API 키가 비어있습니다.", code="ERR_SUPABASE_KEY_EMPTY")
# Supabase REST API 호출
headers = {
"apikey": self.supabase_key,
"Authorization": f"Bearer {self.supabase_key}",
"Content-Type": "application/json"
}
params = {
"select": "*",
"program_id": f"eq.{self.program_id}",
"is_stable": "eq.true",
"order": "created_at.desc",
"limit": "1"
}
logger.info("업데이트 확인 중...")
table_candidates = [self.version_table]
if self.version_table == "program_version":
table_candidates.append("program_versions")
response = None
verify_option = _build_requests_verify_option(self.ssl_verify, self.ca_bundle_path)
for table_name in table_candidates:
url = f"{self.supabase_url}/rest/v1/{table_name}"
response = requests.get(
url,
headers=headers,
params=params,
timeout=10,
verify=verify_option,
)
if response.status_code == 404 and table_name != table_candidates[-1]:
logger.warning(f"버전 테이블 미존재: {table_name}, 다음 후보 시도")
continue
response.raise_for_status()
if table_name != self.version_table:
logger.info(f"버전 테이블 자동 전환: {self.version_table} -> {table_name}")
self.version_table = table_name
break
if response is None:
raise NetworkError("버전 조회 응답이 없습니다.")
data = response.json()
if not data:
logger.info("등록된 버전 정보가 없습니다.")
return None
latest = data[0]
update_level = _normalize_update_level(latest.get("update_level", "patch"))
version_info = VersionInfo(
version=latest.get("version", ""),
is_stable=latest.get("is_stable", True),
release_note=latest.get("release_note"),
download_url=latest.get("download_url"),
min_required_version=latest.get("min_required_version"),
update_level=update_level,
)
self._latest_version = version_info
if update_level not in self.allowed_update_levels:
logger.info(
f"업데이트 레벨 정책으로 건너뜀: level={update_level} / 허용={self.allowed_update_levels}"
)
return None
# 버전 비교
if compare_versions(self.current_version, version_info.version) < 0:
logger.info(f"새 버전 발견: {version_info.version} (level={update_level})")
return version_info
else:
logger.info(f"최신 버전 사용 중: {self.current_version}")
return None
except requests.exceptions.Timeout:
logger.warning("업데이트 서버 연결 시간 초과")
raise NetworkError("서버 연결 시간 초과", code="ERR_TIMEOUT")
except requests.exceptions.SSLError as e:
logger.warning(f"업데이트 SSL 검증 실패: {e}")
raise NetworkError(
"SSL 인증서 검증에 실패했습니다. 내부망 인증서 사용 시 update.ssl_verify 또는 update.ca_bundle_path 설정을 확인하세요.",
code="ERR_SSL_VERIFY",
)
except requests.exceptions.RequestException as e:
logger.warning(f"업데이트 확인 실패: {e}")
raise NetworkError(f"네트워크 오류: {e}", code="ERR_NETWORK_REQUEST")
except ConfigError:
raise
except (KeyError, json.JSONDecodeError) as e:
logger.error(f"버전 정보 파싱 오류: {e}")
self._set_last_error("ERR_RESPONSE_PARSE", str(e))
return None
def is_update_available(self) -> bool:
"""
업데이트 필요 여부 확인 (캐시된 버전 정보 사용)
Returns:
bool: 업데이트 가능 여부
"""
if self._latest_version is None:
return False
return compare_versions(self.current_version, self._latest_version.version) < 0
def prepare_update(self, version_info: VersionInfo) -> tuple[bool, str]:
"""
업데이트 준비
config.json을 생성하고 updater.exe를 임시 폴더로 복사합니다.
Args:
version_info: 업데이트할 버전 정보
Returns:
tuple[bool, str]: (성공 여부, 메시지)
"""
try:
self._clear_last_error()
# 다운로드 URL 확인
if not version_info.download_url:
self._set_last_error("ERR_NO_DOWNLOAD_URL", "다운로드 URL이 없습니다.")
return False, "다운로드 URL이 없습니다."
install_dir = self.install_dir
restart_exe = Path(sys.executable).name if getattr(sys, "frozen", False) else "voc_noti.exe"
# config.json 생성
config = UpdateConfig(
download_url=version_info.download_url,
target_path=str(install_dir),
version=version_info.version,
restart_exe=restart_exe,
update_level=_normalize_update_level(version_info.update_level),
preserve_globs=copy.deepcopy(self.preserve_globs),
cleanup_backup_on_success=self.cleanup_backup_on_success,
clean_install_before_copy=(
self.clean_install_before_copy
or _normalize_update_level(version_info.update_level) in self.clean_install_levels
),
)
temp_config_path = self.config_path.with_suffix(".tmp")
with open(temp_config_path, 'w', encoding='utf-8') as f:
json.dump(
{
"download_url": config.download_url,
"target_path": config.target_path,
"version": config.version,
"restart_exe": config.restart_exe,
"update_level": config.update_level,
"preserve_globs": config.preserve_globs,
"cleanup_backup_on_success": config.cleanup_backup_on_success,
"clean_install_before_copy": config.clean_install_before_copy,
},
f,
ensure_ascii=False,
indent=2,
)
temp_config_path.replace(self.config_path)
logger.info(f"업데이트 설정 저장: {self.config_path}")
# updater.exe 복사
updater_src = install_dir / self.UPDATER_EXE_NAME
if not updater_src.exists():
self._set_last_error("ERR_UPDATER_EXE_NOT_FOUND", f"updater.exe를 찾을 수 없습니다: {updater_src}")
return False, f"updater.exe를 찾을 수 없습니다: {updater_src}"
shutil.copy2(updater_src, self.temp_updater_path)
logger.info(f"updater.exe 복사 완료: {self.temp_updater_path}")
return True, "업데이트 준비 완료"
except PermissionError as e:
logger.error(f"파일 권한 오류: {e}")
self._set_last_error("ERR_PERMISSION", str(e))
return False, f"파일 권한 오류: {e}"
except Exception as e:
logger.error(f"업데이트 준비 실패: {e}")
self._set_last_error("ERR_PREPARE_UNKNOWN", str(e))
return False, str(e)
def launch_updater(self) -> bool:
"""
updater.exe 실행
Returns:
bool: 실행 성공 여부
"""
try:
self._clear_last_error()
if not self.temp_updater_path.exists():
logger.error(f"updater.exe가 없습니다: {self.temp_updater_path}")
self._set_last_error("ERR_UPDATER_TEMP_NOT_FOUND", str(self.temp_updater_path))
return False
# updater.exe 실행
subprocess.Popen(
[str(self.temp_updater_path)],
cwd=str(self.temp_updater_path.parent),
creationflags=self._get_creation_flags(),
)
logger.info("updater.exe 실행 완료")
return True
except Exception as e:
logger.error(f"updater.exe 실행 실패: {e}")
self._set_last_error("ERR_UPDATER_LAUNCH", str(e))
return False
def start_background_check(
self,
on_update_available: Optional[Callable[[VersionInfo], None]] = None,
on_error: Optional[Callable[[Exception], None]] = None
) -> None:
"""
백그라운드 업데이트 체크 시작
Args:
on_update_available: 업데이트 발견 시 콜백
on_error: 에러 발생 시 콜백
"""
def _check_loop():
while not self._stop_flag.is_set():
try:
version_info = self.check_for_updates()
if version_info and on_update_available:
on_update_available(version_info)
except Exception as e:
logger.warning(f"백그라운드 업데이트 체크 오류: {e}")
if on_error:
on_error(e)
# 다음 체크까지 대기
self._stop_flag.wait(self.check_interval * 3600)
thread = threading.Thread(target=_check_loop, daemon=True)
thread.start()
logger.info(f"백그라운드 업데이트 체크 시작 (주기: {self.check_interval}시간)")
def stop_background_check(self) -> None:
"""백그라운드 업데이트 체크 중지"""
self._stop_flag.set()
logger.info("백그라운드 업데이트 체크 중지")
def cleanup(self) -> None:
"""임시 파일 정리"""
try:
if self.config_path.exists():
self.config_path.unlink()
if self.temp_updater_path.exists():
self.temp_updater_path.unlink()
logger.info("업데이트 임시 파일 정리 완료")
except Exception as e:
logger.warning(f"임시 파일 정리 실패: {e}")
# ============================================================================
# 편의 함수
# ============================================================================
def create_update_manager_from_settings(settings: dict) -> UpdateManager:
"""
settings.json에서 UpdateManager 생성
Args:
settings: settings.json에서 로드한 딕셔너리
Returns:
UpdateManager 인스턴스
"""
update_settings = settings.get("update", {})
# settings.json에서 직접 지정 시 우선 사용, 없으면 updater/config.json 사용
supabase_url = str(update_settings.get("supabase_url", "")).strip()
supabase_key = str(update_settings.get("supabase_key", "")).strip()
ssl_verify_raw = update_settings.get("ssl_verify", None)
ca_bundle_path = str(update_settings.get("ca_bundle_path", "")).strip()
config_path_raw = str(update_settings.get("connection_config_path", "")).strip()
config_path = _resolve_connection_config_path(config_path_raw) if config_path_raw else None
local_policy = _load_local_update_policy(config_path)
preserve_globs = update_settings.get("preserve_globs", local_policy.get("preserve_globs", ["data/*.sqlite", "data/*.json", "data/*.db"]))
if not isinstance(preserve_globs, list):
preserve_globs = ["data/*.sqlite", "data/*.json", "data/*.db"]
preserve_globs = [str(p).strip() for p in preserve_globs if str(p).strip()]
cleanup_backup_on_success = _parse_ssl_verify(
update_settings.get("cleanup_backup_on_success", local_policy.get("cleanup_backup_on_success", True)),
default=True,
)
clean_install_before_copy = _parse_ssl_verify(
update_settings.get("clean_install_before_copy", local_policy.get("clean_install_before_copy", False)),
default=False,
)
allowed_update_levels = _normalize_level_list(
update_settings.get("allowed_update_levels", local_policy.get("allowed_update_levels", ["patch", "minor", "major"])),
["patch", "minor", "major"],
)
clean_install_levels = _normalize_level_list(
update_settings.get("clean_install_levels", local_policy.get("clean_install_levels", ["minor", "major"])),
["minor", "major"],
)
if not supabase_url or not supabase_key:
env_name = update_settings.get("environment")
conn = load_updater_connection_config(config_path=config_path, environment=env_name)
supabase_url = conn["supabase_url"]
supabase_key = conn["supabase_key"]
if ssl_verify_raw is None:
ssl_verify_raw = conn.get("ssl_verify", True)
if not ca_bundle_path:
ca_bundle_path = str(conn.get("ca_bundle_path", "")).strip()
logger.info(
f"업데이터 연결 설정 로드: source={conn.get('source')} / environment={conn.get('environment')}"
)
ssl_verify = _parse_ssl_verify(ssl_verify_raw, default=True)
return UpdateManager(
supabase_url=supabase_url,
supabase_key=supabase_key,
program_id=update_settings.get("program_id", PROGRAM_ID),
current_version=update_settings.get("current_version", VERSION),
check_interval=update_settings.get("check_interval_hours", 1),
version_table=update_settings.get("version_table", "program_version"),
ssl_verify=ssl_verify,
ca_bundle_path=ca_bundle_path,
preserve_globs=preserve_globs,
cleanup_backup_on_success=cleanup_backup_on_success,
clean_install_before_copy=clean_install_before_copy,
allowed_update_levels=allowed_update_levels,
clean_install_levels=clean_install_levels,
)