Created
March 5, 2026 13:40
-
-
Save RichardDally/27c1a77e5e2c74fc5a093cd1d82f19ed to your computer and use it in GitHub Desktop.
TKinter self updater POC
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import tkinter as tk | |
| from tkinter import ttk, messagebox | |
| import httpx | |
| import os | |
| import sys | |
| import platform | |
| import subprocess | |
| import threading | |
| from typing import Dict, Optional, Union | |
| # --- Configuration --- | |
| LOCAL_VERSION_FILE: str = "version.txt" | |
| ARTIFACTORY_URL: str = "https://artifactory.your-company.com/artifactory/your-repo" | |
| AUTH_TOKEN: str = "YOUR_ARTIFACTORY_ACCESS_TOKEN" | |
| APP_NAME_BASE: str = "my-application" | |
| # Optional: Path to a custom CA certificate bundle (.pem) for internal servers. | |
| # Set to None or an empty string to use default system certificates. | |
| CA_CERT_PATH: Optional[str] = None | |
| def get_local_version() -> str: | |
| """Reads the local version from the version file.""" | |
| if os.path.exists(LOCAL_VERSION_FILE): | |
| with open(LOCAL_VERSION_FILE, "r", encoding="utf-8") as f: | |
| return f.read().strip() | |
| return "0.0.0" | |
| def clean_old_updates() -> None: | |
| """Removes the old Windows binaries renamed during a previous update.""" | |
| current_exe: str = sys.executable if getattr(sys, 'frozen', False) else os.path.abspath(__file__) | |
| old_exe: str = f"{current_exe}.old" | |
| if os.path.exists(old_exe): | |
| try: | |
| os.remove(old_exe) | |
| except OSError: | |
| pass | |
| def start_update_thread() -> None: | |
| """Starts the update process in a separate thread.""" | |
| btn_update.config(state=tk.DISABLED, text="Checking...") | |
| progress_bar["value"] = 0 | |
| lbl_progress.config(text="") | |
| threading.Thread(target=perform_update, daemon=True).start() | |
| def update_progress_ui(downloaded_bytes: int, total_bytes: int) -> None: | |
| """Safely updates the progress bar and label from the main thread.""" | |
| if total_bytes > 0: | |
| percent: float = (downloaded_bytes / total_bytes) * 100 | |
| progress_bar["value"] = percent | |
| mb_downloaded: float = downloaded_bytes / (1024 * 1024) | |
| mb_total: float = total_bytes / (1024 * 1024) | |
| lbl_progress.config(text=f"{percent:.1f}% ({mb_downloaded:.2f} MB / {mb_total:.2f} MB)") | |
| else: | |
| progress_bar.config(mode="indeterminate") | |
| if downloaded_bytes == 8192: | |
| progress_bar.start() | |
| mb_downloaded_fallback: float = downloaded_bytes / (1024 * 1024) | |
| lbl_progress.config(text=f"Downloaded {mb_downloaded_fallback:.2f} MB") | |
| def perform_update() -> None: | |
| """Main update logic running in the background thread.""" | |
| headers: Dict[str, str] = {"Authorization": f"Bearer {AUTH_TOKEN}"} | |
| local_version: str = get_local_version() | |
| # Determine SSL verification strategy: use custom CA path if provided and exists, else default to True | |
| ssl_verify: Union[str, bool] = CA_CERT_PATH if CA_CERT_PATH and os.path.exists(CA_CERT_PATH) else True | |
| try: | |
| # Pass the verify parameter to the httpx.Client | |
| with httpx.Client(http2=True, verify=ssl_verify) as client: | |
| response: httpx.Response = client.get(f"{ARTIFACTORY_URL}/version.txt", headers=headers, timeout=5.0) | |
| response.raise_for_status() | |
| remote_version: str = response.text.strip() | |
| except httpx.RequestError as e: | |
| root.after(0, lambda err=e: messagebox.showerror("Error", f"Unable to reach Artifactory:\n{err}")) | |
| root.after(0, reset_button) | |
| return | |
| if remote_version <= local_version: | |
| root.after(0, lambda: messagebox.showinfo("Up to date", f"You already have the latest version ({local_version}).")) | |
| root.after(0, reset_button) | |
| return | |
| system: str = platform.system().lower() | |
| binary_name: str | |
| if system == "windows": | |
| binary_name = f"{APP_NAME_BASE}-windows.exe" | |
| elif system == "darwin": | |
| binary_name = f"{APP_NAME_BASE}-macos" | |
| else: | |
| binary_name = f"{APP_NAME_BASE}-linux" | |
| download_url: str = f"{ARTIFACTORY_URL}/{remote_version}/{binary_name}" | |
| current_exe: str = sys.executable if getattr(sys, 'frozen', False) else os.path.abspath(__file__) | |
| try: | |
| root.after(0, lambda: btn_update.config(text="Downloading...")) | |
| # Apply the verify parameter here as well | |
| with httpx.Client(http2=True, verify=ssl_verify) as client: | |
| with client.stream("GET", download_url, headers=headers) as stream_response: | |
| stream_response.raise_for_status() | |
| total_bytes: int = int(stream_response.headers.get("Content-Length", 0)) | |
| downloaded_bytes: int = 0 | |
| if system == "windows" and getattr(sys, 'frozen', False): | |
| os.rename(current_exe, current_exe + ".old") | |
| with open(current_exe, "wb") as f: | |
| chunk: bytes | |
| for chunk in stream_response.iter_bytes(chunk_size=8192): | |
| f.write(chunk) | |
| downloaded_bytes += len(chunk) | |
| root.after(0, update_progress_ui, downloaded_bytes, total_bytes) | |
| if system != "windows": | |
| os.chmod(current_exe, 0o755) | |
| with open(LOCAL_VERSION_FILE, "w", encoding="utf-8") as f: | |
| f.write(remote_version) | |
| root.after(0, lambda: messagebox.showinfo("Success", "Update complete. The application will now restart.")) | |
| subprocess.Popen([current_exe]) | |
| os._exit(0) | |
| except Exception as e: | |
| if system == "windows" and getattr(sys, 'frozen', False) and os.path.exists(current_exe + ".old"): | |
| try: | |
| os.replace(current_exe + ".old", current_exe) | |
| except OSError: | |
| pass | |
| root.after(0, lambda err=e: messagebox.showerror("Error", f"Update failed:\n{err}")) | |
| root.after(0, reset_button) | |
| def reset_button() -> None: | |
| """Resets the UI elements if an update fails or completes.""" | |
| btn_update.config(state=tk.NORMAL, text="Update Now") | |
| progress_bar.stop() | |
| progress_bar.config(mode="determinate") | |
| progress_bar["value"] = 0 | |
| lbl_progress.config(text="") | |
| # --- Tkinter UI --- | |
| if __name__ == "__main__": | |
| clean_old_updates() | |
| root: tk.Tk = tk.Tk() | |
| root.title("My Application") | |
| root.geometry("400x250") | |
| lbl_title: tk.Label = tk.Label(root, text="Auto-Updatable App", font=("Arial", 14)) | |
| lbl_title.pack(pady=10) | |
| lbl_version: tk.Label = tk.Label(root, text=f"Current Version: {get_local_version()}") | |
| lbl_version.pack(pady=5) | |
| progress_bar: ttk.Progressbar = ttk.Progressbar(root, orient="horizontal", length=300, mode="determinate") | |
| progress_bar.pack(pady=10) | |
| lbl_progress: tk.Label = tk.Label(root, text="", font=("Arial", 9), fg="gray") | |
| lbl_progress.pack() | |
| btn_update: tk.Button = tk.Button(root, text="Update Now", command=start_update_thread, width=20, bg="#4CAF50", fg="white", font=("Arial", 10, "bold")) | |
| btn_update.pack(pady=15) | |
| root.mainloop() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment