Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save RichardDally/27c1a77e5e2c74fc5a093cd1d82f19ed to your computer and use it in GitHub Desktop.

Select an option

Save RichardDally/27c1a77e5e2c74fc5a093cd1d82f19ed to your computer and use it in GitHub Desktop.
TKinter self updater POC
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