diff --git a/compile.py b/compile.py index a2bcdfd..2b09205 100644 --- a/compile.py +++ b/compile.py @@ -1,4 +1,5 @@ import os +import sys import platform import subprocess import plistlib @@ -25,7 +26,7 @@ def build(): icon = "data/icon-win.png" if system == "Windows" else "data/icon.png" args = [ - "pyinstaller", + sys.executable, "-m", "PyInstaller", f"--name={name}", "--onefile", "--clean", diff --git a/ghost.py b/ghost.py index 3742bb9..6112381 100755 --- a/ghost.py +++ b/ghost.py @@ -15,7 +15,7 @@ from utils.files import get_application_support from utils.config import Config -from utils import startup_check, check_fonts, console, load_fonts, is_admin, run_elevated, relaunch_normal, send_telemetry_ping +from utils import startup_check, check_fonts, console, load_fonts, is_admin, run_elevated, relaunch_normal, send_telemetry_ping, get_update_info from bot.controller import BotController @@ -34,13 +34,13 @@ def parse_args(): ) return parser.parse_args() -def run_gui(): +def run_gui(update_info=None): from gui.main import GhostGUI cfg = Config() controller = BotController() - GhostGUI(controller).run() + GhostGUI(controller, update_info=update_info).run() def run_cli(): @@ -70,6 +70,7 @@ def main(): headless = args.headless get_application_support() + update_info = get_update_info() startup_check.check() cfg = Config() cfg.check() @@ -82,11 +83,15 @@ def main(): console.info("Running in GUI mode.") + if update_info and update_info.has_update: + run_gui(update_info=update_info) + return + if cfg.get_skip_fonts(): cfg.set_skip_fonts(False) - run_gui() + run_gui(update_info=update_info) elif check_fonts(): - run_gui() + run_gui(update_info=update_info) elif args.install_fonts: load_fonts() if check_fonts(): @@ -96,7 +101,7 @@ def main(): run_elevated() return load_fonts() - run_gui() + run_gui(update_info=update_info) if __name__ == "__main__": main() diff --git a/gui/main.py b/gui/main.py index c47c9a0..1892913 100644 --- a/gui/main.py +++ b/gui/main.py @@ -11,16 +11,18 @@ from utils.config import Config import utils.console as logging from utils.files import resource_path +from utils import UpdateInfo -from gui.pages import HomePage, LoadingPage, SettingsPage, OnboardingPage, ScriptsPage, ToolsPage +from gui.pages import HomePage, LoadingPage, SettingsPage, OnboardingPage, ScriptsPage, ToolsPage, UpdatePage from gui.components import Sidebar, Console, Titlebar, RoundedFrame from gui.helpers import Images, Layout, Style, apply_theme class GhostGUI: - def __init__(self, bot_controller): + def __init__(self, bot_controller, update_info=None): self.resize_grip_size = 5 self.size = (750, 530) self.bot_controller = bot_controller + self.update_info = update_info self.resize_grips = {} self.root = ttk.tk.Tk() @@ -84,6 +86,7 @@ def __init__(self, bot_controller): self.settings_page = SettingsPage(self.root, self.bot_controller, self.draw_settings) self.scripts_page = ScriptsPage(self, self.bot_controller, self.images) self.tools_page = ToolsPage(self.root, self.bot_controller, self.images, self.layout, self._position_resize_grips) + self.update_page = UpdatePage(self.root, self) logging.set_gui(self) @@ -249,6 +252,33 @@ def draw_onboarding(self): main = self.layout.main(scrollable=False) self.onboarding_page.draw(main) self.root.after(150, self._position_resize_grips) + + def draw_update(self): + self.sidebar.set_current_page("onboarding") + self.layout.clear() + main = self.layout.main(scrollable=False) + self.update_page.draw(main) + self.root.after(150, self._position_resize_grips) + + def _continue_after_update_prompt(self): + self.update_info = None + + if self.cfg.get("token") == "": + self.draw_onboarding() + return + + if not self.bot_controller.running: + self.bot_controller.start() + + self.root.after(25, self.sidebar.disable) + self.draw_home(start=True) + self.root.after(100, self._pre_load_images) + + if sys.platform == "win32": + self.root.after(50, lambda: hPyT.window_frame.restore(self.root)) + self.root.after(75, lambda: hPyT.window_frame.center(self.root)) + + self.root.after(100, self._check_bot_started) # def draw_console(self): # self.sidebar.set_current_page("console") @@ -328,29 +358,13 @@ def run(self, preserve_position=False): if not preserve_position: self.layout.center_window(self.size[0], self.size[1]) - if self.cfg.get("token") == "": - # if True: - self.draw_onboarding() + if self.update_info and self.update_info.has_update: + self.layout.resize(self.size[0], self.size[1] + 50) + self.layout.center_window(self.size[0], self.size[1] + 50) + self.draw_update() self.root.mainloop() return - - if not self.bot_controller.running: - self.bot_controller.start() - - # self.layout.hide_titlebar() - # self.layout.stick_window() - # self.layout.resize(400, 90) - # self.layout.center_window(400, 90) - # self.loading_page.draw() - self.root.after(25, self.sidebar.disable) - self.draw_home(start=True) - self.root.after(100, self._pre_load_images) - - if sys.platform == "win32": - self.root.after(50, lambda: hPyT.window_frame.restore(self.root)) - self.root.after(75, lambda: hPyT.window_frame.center(self.root)) - - self.root.after(100, self._check_bot_started) + self._continue_after_update_prompt() self.root.mainloop() def quit(self): diff --git a/gui/pages/__init__.py b/gui/pages/__init__.py index a6cfea0..24c3841 100644 --- a/gui/pages/__init__.py +++ b/gui/pages/__init__.py @@ -4,4 +4,5 @@ from .onboarding import OnboardingPage from .scripts import ScriptsPage from .script import ScriptPage -from .tools import ToolsPage \ No newline at end of file +from .tools import ToolsPage +from .update import UpdatePage \ No newline at end of file diff --git a/gui/pages/update.py b/gui/pages/update.py new file mode 100644 index 0000000..b91108a --- /dev/null +++ b/gui/pages/update.py @@ -0,0 +1,214 @@ +import re +import threading +import webbrowser + +import ttkbootstrap as ttk + +from gui.components import RoundedFrame, RoundedButton +from gui.helpers import Style + +class UpdatePage: + def __init__(self, root, master): + self.root = root + self.master = master + self.installing = False + + def _build_markdown_tags(self, text_widget): + base_font = ("Host Grotesk", 12) + text_widget.tag_configure("heading1", font=("Host Grotesk", 16, "bold"), spacing1=10, spacing3=8) + text_widget.tag_configure("heading2", font=("Host Grotesk", 14, "bold"), spacing1=8, spacing3=6) + text_widget.tag_configure("heading3", font=("Host Grotesk", 13, "bold"), spacing1=6, spacing3=4) + text_widget.tag_configure("bold", font=("Host Grotesk", 12, "bold")) + text_widget.tag_configure("italic", font=("Host Grotesk", 12, "italic")) + text_widget.tag_configure("underline", font=("Host Grotesk", 12, "underline")) + text_widget.tag_configure("inline_code", font=("JetBrains Mono", 11), background=self.root.style.colors.get("secondary"), foreground=self.root.style.colors.get("text")) + text_widget.tag_configure("blockquote", lmargin1=0, lmargin2=0, foreground="#b3b3b3") + text_widget.tag_configure("bullet", lmargin1=18, lmargin2=36) + text_widget.tag_configure("plain", font=base_font) + text_widget.tag_configure("warning", font=("Host Grotesk", 13, "bold"), foreground="#facc15", spacing1=6, spacing3=4) + text_widget.tag_configure("note", font=("Host Grotesk", 13, "bold"), foreground="#158bfa", spacing1=6, spacing3=4) + + def _insert_inline_markdown(self, text_widget, line, base_tags=()): + patterns = [ + (r"(.*?)", "underline"), + (r"`([^`]+)`", "inline_code"), + (r"\*\*([^*]+)\*\*", "bold"), + (r"(?\s*(WARNING|NOTE)\s*$", stripped) + if admonition: + tag = "warning" if admonition.group(1) == "WARNING" else "note" + text_widget.insert("end", admonition.group(1) + "\n", (tag,)) + continue + if stripped.startswith(("- ", "* ")): + prefix = "• " if stripped.startswith(("- ", "* ")) else "" + text_widget.insert("end", prefix) + self._insert_inline_markdown(text_widget, stripped[2:]) + text_widget.insert("end", "\n", ("bullet",)) + continue + if stripped.startswith(">"): + blockquote_content = stripped[1:].strip() + self._insert_inline_markdown(text_widget, blockquote_content, ("blockquote",)) + text_widget.insert("end", "\n", ("blockquote",)) + continue + + self._insert_inline_markdown(text_widget, stripped) + text_widget.insert("end", "\n", ("plain",)) + + def _set_install_progress(self, status, progress): + if not self.installing: + return + + self.install_status.configure(text=status) + if progress is None: + if self.install_progress.cget("mode") != "indeterminate": + self.install_progress.configure(mode="indeterminate") + self.install_progress.start(12) + return + + if self.install_progress.cget("mode") == "indeterminate": + self.install_progress.stop() + self.install_progress.configure(mode="determinate") + self.install_progress.configure(value=progress) + + def _install_update(self, update_info): + self.installing = True + self.install_button.set_state("disabled") + self.skip_button.set_state("disabled") + self._set_install_progress("Starting update", 0) + + def report(status, progress): + self.root.after(0, self._set_install_progress, status, progress) + + def install(): + try: + installed = update_info.install(progress_callback=report, exit_on_success=False) + except Exception as exc: + self.root.after(0, self._update_failed, str(exc)) + return + + if installed: + self.root.after(0, self._finish_install) + else: + self.root.after(0, self._update_failed, "Could not install the update. Check the console for details.") + + threading.Thread(target=install, daemon=True).start() + + def _finish_install(self): + self.install_status.configure(text="Closing Ghost") + self.root.after(100, self.master.quit) + + def _update_failed(self, message): + self.installing = False + self.install_progress.stop() + self.install_progress.configure(mode="determinate", value=0) + self.install_status.configure(text=message) + self.install_button.set_state("normal") + self.skip_button.set_state("normal") + + def draw(self, wrapper): + update_info = getattr(self.master, "update_info", None) + + ttk.Label(wrapper, text="New Update Available", font=("Host Grotesk", 24, "bold")).pack(anchor="w", padx=24, pady=(24, 10)) + + version_text = "A new version is ready to install." + if update_info: + version_text = f"Version {update_info.latest_version} is ready to install." + + ttk.Label(wrapper, text=version_text, wraplength=500, justify="left").pack(anchor="w", padx=24) + + card = RoundedFrame(wrapper, radius=(18, 18, 0, 0), background=self.root.style.colors.get("secondary")) + card.pack(fill=ttk.BOTH, expand=True, padx=24, pady=24) + + changelog_label = ttk.Label(card, text="Changelog", font=("Host Grotesk", 14, "bold")) + changelog_label.configure(background=self.root.style.colors.get("secondary")) + changelog_label.pack(anchor="w", pady=(10, 10), padx=10) + + if update_info and update_info.changelog: + changelog_frame = RoundedFrame(card, radius=(0, 0, 18, 18), background=self.root.style.colors.get("dark"), parent_background=self.root.style.colors.get("bg")) + changelog_frame.pack(fill=ttk.BOTH, expand=True) + + changelog = ttk.tk.Text(changelog_frame, height=7, wrap="word", borderwidth=0, highlightthickness=0) + self._render_markdown(changelog, update_info.changelog.strip()) + changelog.configure(state="disabled", background=self.root.style.colors.get("dark"), foreground=self.root.style.colors.get("text"), borderwidth=0, highlightthickness=0, font=("Host Grotesk", 12)) + changelog.pack(fill=ttk.BOTH, expand=True, padx=10, pady=10) + + button_row = ttk.Frame(wrapper) + button_row.pack(fill=ttk.X, padx=24, pady=(8, 24)) + + self.install_status = ttk.Label(button_row, text="", font=("Host Grotesk", 11)) + self.install_status.pack(side=ttk.TOP, anchor="w", pady=(0, 6)) + self.install_progress = ttk.Progressbar(button_row, mode="determinate", maximum=100, value=0, bootstyle="primary") + self.install_progress.pack(side=ttk.TOP, fill=ttk.X, pady=(0, 12)) + + full_changelog_button = RoundedButton( + button_row, + text="Full Changelog", + command=lambda e: webbrowser.open(update_info.full_changelog_url) if update_info else None, + bootstyle="secondary.TButton", + ) + full_changelog_button.pack(side=ttk.LEFT) + + self.install_button = RoundedButton(button_row, text="Install Update", command=lambda e: self._install_update(update_info) if update_info else None, bootstyle="primary.TButton") + self.install_button.pack(side=ttk.RIGHT) + + self.skip_button = RoundedButton(button_row, text="Skip", command=lambda e: self.master._continue_after_update_prompt(), bootstyle="secondary.TButton") + self.skip_button.pack(side=ttk.RIGHT, padx=(0, 10)) diff --git a/utils/__init__.py b/utils/__init__.py index d1feb39..11aba4a 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -1,9 +1,10 @@ from .startup_check import check from .notifier import Notifier from .webhook import Webhook -from .config import Config, RichPresence, Sniper, Theme, Token, VERSION, PRODUCTION, MOTD, CHANGELOG +from .config import Config, RichPresence, Sniper, Theme, Token, VERSION, PRODUCTION, MOTD, CHANGELOG, REPO from .console import get_formatted_time from .files import resource_path from .fonts import load_fonts, uninstall_fonts, check_fonts, get_fonts, is_admin, run_elevated, relaunch_normal from .defaults import DEFAULT_CONFIG, DEFAULT_THEME, DEFAULT_RPC, DEFAULT_SCRIPT -from .telemetry import send_telemetry_ping \ No newline at end of file +from .telemetry import send_telemetry_ping +from .updater import UpdateInfo, check_for_updates, get_update_info, install_update, should_update \ No newline at end of file diff --git a/utils/config/__init__.py b/utils/config/__init__.py index 0a50ddd..5e81bc7 100644 --- a/utils/config/__init__.py +++ b/utils/config/__init__.py @@ -4,7 +4,8 @@ from .config import Config from .token import Token -VERSION = "4.2.3-dev" +VERSION = "4.2.1" PRODUCTION = False MOTD = "lil hotfix cuz i forgor bugs" -CHANGELOG = """""" \ No newline at end of file +CHANGELOG = """""" +REPO = "https://github.com/ghostselfbot/ghost" \ No newline at end of file diff --git a/utils/console.py b/utils/console.py index ac00eeb..7606f17 100644 --- a/utils/console.py +++ b/utils/console.py @@ -1,13 +1,32 @@ import os import sys -import logging import datetime import colorama import pystyle from . import config +from . import files + +_log_file = None + +def _setup_logger(): + global _log_filez + try: + log_path = os.path.join(files.get_data_path(), "ghost.log") + _log_file = open(log_path, "a", encoding="utf-8") + except: + pass + +def _file_log(text): + if _log_file is None: + _setup_logger() + if _log_file: + try: + _log_file.write(f"[{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {text}\n") + _log_file.flush() + except: + pass -# handler = logging.FileHandler(filename='ghost.log', encoding='utf-8', mode='w') gui = None banner = f""" ▄████ ██░ ██ ▒█████ ██████ ▄▄▄█████▓ ██▒ ▀█▒▓██░ ██▒▒██▒ ██▒▒██ ▒ ▓ ██▒ ▓▒ @@ -75,7 +94,9 @@ def print_banner(): def _log_and_print(prefix, colour, text, gui_log=True): # print(f"[{prefix}] {text}") + line = f"[{get_formatted_time()}] [{prefix}] {text}" print(f"{colorama.Style.NORMAL}{colorama.Fore.WHITE}[{get_formatted_time()}] {colour}{colorama.Style.BRIGHT}[{prefix}]{colorama.Style.RESET_ALL} {text}") + _file_log(line) if gui_log: try: log_to_gui(prefix, text) diff --git a/utils/updater.py b/utils/updater.py new file mode 100644 index 0000000..90cf2eb --- /dev/null +++ b/utils/updater.py @@ -0,0 +1,315 @@ +import os +from dataclasses import dataclass +import shlex +import subprocess +import sys +import tempfile +import zipfile + +import requests + +from utils import VERSION, console, REPO, files + +MACOS = "https://github.com/ghostselfbot/ghost/releases/latest/download/Ghost-Mac.zip" +WINDOWS = "https://github.com/ghostselfbot/ghost/releases/latest/download/Ghost-Windows.zip" + + +def _compare_url(current_version, latest_version): + return f"{REPO}/compare/{_normalize_release_tag(current_version)}...{_normalize_release_tag(latest_version)}" + + +@dataclass +class UpdateInfo: + current_version: str + latest_version: str + changelog: str = "" + + @property + def full_changelog_url(self): + return _compare_url(self.current_version, self.latest_version) + + @property + def has_update(self): + return _should_update(self.current_version, self.latest_version) + + def install(self, progress_callback=None, exit_on_success=True): + return install_update(self, progress_callback=progress_callback, exit_on_success=exit_on_success) + + +def _normalize_version(version): + version = str(version).strip() + if version.startswith(("v", "V")): + version = version[1:] + + base_version = version.split("-", 1)[0] + parts = [] + for part in base_version.split("."): + if part.isdigit(): + parts.append(int(part)) + else: + break + + return tuple(parts) + + +def _should_update(current_version, latest_version): + current_parts = _normalize_version(current_version) + latest_parts = _normalize_version(latest_version) + + max_len = max(len(current_parts), len(latest_parts)) + current_parts += (0,) * (max_len - len(current_parts)) + latest_parts += (0,) * (max_len - len(latest_parts)) + + return current_parts < latest_parts + + +def should_update(): + update_info = get_update_info() + return bool(update_info and update_info.has_update) + + +def _release_api_url(): + return REPO.replace("https://github.com/", "https://api.github.com/repos/") + "/releases/latest" + + +def _normalize_release_tag(tag_name): + tag_name = str(tag_name).strip() + if tag_name.startswith(("v", "V")): + return tag_name[1:] + return tag_name + + +def _fetch_release_metadata(): + response = requests.get(_release_api_url(), timeout=5, headers={"Accept": "application/vnd.github+json"}) + response.raise_for_status() + release_data = response.json() + latest_version = _normalize_release_tag(release_data.get("tag_name", VERSION)) + changelog = _strip_admonitions(release_data.get("body", "") or "") + return latest_version, changelog + + +def _strip_admonitions(changelog): + cleaned_lines = [] + + for line in changelog.splitlines(): + line = line.replace("[!NOTE]", "NOTE") + line = line.replace("[!WARNING]", "WARNING") + cleaned_lines.append(line) + + return "\n".join(cleaned_lines).strip() + + +def get_update_info(): + try: + latest_version, changelog = _fetch_release_metadata() + if _should_update(VERSION, latest_version): + return UpdateInfo(current_version=VERSION, latest_version=latest_version, changelog=changelog) + + return None + except requests.exceptions.RequestException as exc: + console.error(f"Failed to check for updates: {exc}") + return None + + +def check_for_updates(): + update_info = get_update_info() + if update_info: + console.info(f"A new version is available: {update_info.latest_version}. You are currently on {update_info.current_version}.") + return update_info + + console.success("You are using the latest version.") + return None + + +def _get_update_url(): + if sys.platform == "darwin": + return MACOS + if sys.platform == "win32": + return WINDOWS + raise RuntimeError("Unsupported platform for automatic updates.") + + +def _find_executable(extract_dir): + if sys.platform == "darwin": + app_paths = [] + for root, dirs, _ in os.walk(extract_dir): + for dirname in dirs: + if dirname.lower().endswith(".app"): + app_paths.append(os.path.join(root, dirname)) + return app_paths[0] if app_paths else None + + if sys.platform == "win32": + exe_paths = [] + for root, _, files in os.walk(extract_dir): + for filename in files: + if filename.lower().endswith(".exe"): + exe_paths.append(os.path.join(root, filename)) + return exe_paths[0] if exe_paths else None + + return None + + +def _prepare_macos_app(app_path): + if sys.platform != "darwin": + return + + subprocess.run(["xattr", "-cr", app_path], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + subprocess.run(["chmod", "-R", "u+rwX", app_path], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + macos_bin_dir = os.path.join(app_path, "Contents", "MacOS") + if os.path.isdir(macos_bin_dir): + for entry in os.listdir(macos_bin_dir): + executable_file = os.path.join(macos_bin_dir, entry) + subprocess.run(["chmod", "+x", executable_file], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def _extract_update_archive(archive_path, extract_dir): + if sys.platform == "darwin": + subprocess.run(["ditto", "-x", "-k", archive_path, extract_dir], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return + + with zipfile.ZipFile(archive_path, "r") as archive: + archive.extractall(extract_dir) + + +def _current_install_path(): + if not getattr(sys, "frozen", False): + return None + + if sys.platform == "darwin": + return os.path.realpath(os.path.dirname(os.path.dirname(os.path.dirname(sys.executable)))) + + if sys.platform == "win32": + return os.path.realpath(sys.executable) + + return None + + +def _start_replacement_handoff(current_path, updated_path, update_dir): + current_pid = str(os.getpid()) + + if sys.platform == "darwin": + script_path = os.path.join(update_dir, "install-update.sh") + log_path = os.path.join(update_dir, "install-update.log") + updated_executable = os.path.join(updated_path, "Contents", "MacOS", os.path.basename(sys.executable)) + current_executable = os.path.join(current_path, os.path.relpath(updated_executable, updated_path)) + script = "\n".join([ + "#!/bin/sh", + f"exec >> {shlex.quote(log_path)} 2>&1", + f"while kill -0 {current_pid} 2>/dev/null; do sleep 1; done", + f"rm -rf {shlex.quote(current_path)}", + f"mv {shlex.quote(updated_path)} {shlex.quote(current_path)}", + "unset _PYI_APPLICATION_HOME_DIR _PYI_PARENT_PROCESS_LEVEL _PYI_SPLASH_IPC", + "export PYINSTALLER_RESET_ENVIRONMENT=1", + f"{shlex.quote(current_executable)} &", + f"rm -rf {shlex.quote(update_dir)}", + "exit 0", + ]) + with open(script_path, "w", encoding="utf-8") as script_file: + script_file.write(f"{script}\n") + os.chmod(script_path, 0o700) + subprocess.Popen(["/bin/sh", script_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return + + if sys.platform == "win32": + script_path = os.path.join(update_dir, "install-update.cmd") + powershell_update_dir = update_dir.replace("'", "''") + script = "\r\n".join([ + "@echo off", + f"powershell -NoProfile -Command \"Wait-Process -Id {current_pid} -ErrorAction SilentlyContinue\"", + f"move /Y \"{updated_path}\" \"{current_path}\" >nul", + "set \"_PYI_APPLICATION_HOME_DIR=\"", + "set \"_PYI_PARENT_PROCESS_LEVEL=\"", + "set \"_PYI_SPLASH_IPC=\"", + "set \"PYINSTALLER_RESET_ENVIRONMENT=1\"", + f"start \"\" \"{current_path}\"", + f"start \"\" /b powershell -NoProfile -WindowStyle Hidden -Command \"Start-Sleep -Seconds 2; Remove-Item -LiteralPath '{powershell_update_dir}' -Recurse -Force\"", + ]) + with open(script_path, "w", encoding="utf-8") as script_file: + script_file.write(f"{script}\r\n") + subprocess.Popen(["cmd", "/c", script_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return + + raise RuntimeError("Unsupported platform for automatic updates.") + + +def install_update(update_info=None, progress_callback=None, exit_on_success=True): + def report(status, progress=None): + if progress_callback: + progress_callback(status, progress) + + try: + url = _get_update_url() + except RuntimeError as exc: + console.error(f"{exc}") + return False + + try: + report("Downloading update", 0) + console.info(f"Starting download from {url}") + response = requests.get(url, stream=True, timeout=30) + response.raise_for_status() + filename = os.path.basename(url) + update_dir = os.path.join(files.get_application_support(), ".ghost-updates") + os.makedirs(update_dir, exist_ok=True) + archive_path = os.path.join(update_dir, filename) + content_length = int(response.headers.get("content-length", 0)) + downloaded_bytes = 0 + if not content_length: + report("Downloading update", None) + + with open(archive_path, "wb") as update_file: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + update_file.write(chunk) + downloaded_bytes += len(chunk) + if content_length: + report("Downloading update", downloaded_bytes * 100 / content_length) + + console.info(f"Downloaded update archive ({downloaded_bytes} bytes): {filename}") + console.info(f"Saving archive to: {archive_path}") + + report("Preparing update", None) + extract_dir = tempfile.mkdtemp(prefix="ghost-update-", dir=update_dir) + console.info(f"Extracting update to: {extract_dir}") + _extract_update_archive(archive_path, extract_dir) + + console.info(f"Archive extracted to: {extract_dir}") + + executable_path = _find_executable(extract_dir) + if not executable_path: + console.error("Could not find an executable inside the downloaded update archive.") + return False + + console.info(f"Found updated executable: {executable_path}") + + current_path = _current_install_path() + if not current_path: + console.error("Automatic updates are only available from an installed Ghost application.") + return False + + console.info(f"Current install path: {current_path}") + + if sys.platform == "darwin" and executable_path.lower().endswith(".app"): + report("Preparing application", None) + console.info("Preparing macOS app bundle") + _prepare_macos_app(executable_path) + console.info("macOS app bundle prepared") + + report("Restarting Ghost", None) + console.info(f"Starting replacement handoff: {current_path} <- {executable_path}") + _start_replacement_handoff(current_path, executable_path, update_dir) + console.info(f"Installed update to {current_path}; restarting Ghost.") + if exit_on_success: + raise SystemExit(0) + return True + except requests.exceptions.RequestException as exc: + console.error(f"Failed to download update: {exc}") + return False + except (OSError, zipfile.BadZipFile) as exc: + console.error(f"Failed to install update: {exc}") + return False + + +def download_update(): + return install_update() \ No newline at end of file