From 82411a47ea052d38fa47f8288097558900b78bf6 Mon Sep 17 00:00:00 2001 From: Umiko Date: Fri, 2 May 2025 07:05:17 +0700 Subject: [PATCH] initial commit --- .gitignore | 42 ++++++++ Generator.py | 62 ++++++++++++ MIT-LICENSE.txt | 12 +++ README.md | 166 ++++++++++++++++++++++++++++++ Updater.py | 255 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 2 + 6 files changed, 539 insertions(+) create mode 100644 .gitignore create mode 100644 Generator.py create mode 100644 MIT-LICENSE.txt create mode 100644 README.md create mode 100644 Updater.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..97d9f21 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +build/ +dist/ +*.egg-info/ +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.cache +.pytest_cache/ + +# VS Code +.vscode/ + +# Compiled executable files +*.exe + +# System files +.DS_Store +Thumbs.db + +# Environment files +.env +.venv/ +ENV/ +env/ + +# Manifest files +manifest.json +#Add more if you need. \ No newline at end of file diff --git a/Generator.py b/Generator.py new file mode 100644 index 0000000..c635c96 --- /dev/null +++ b/Generator.py @@ -0,0 +1,62 @@ +import os +import hashlib +import json +import argparse + +excluded_paths = [ + 'manifest.json', + 'Updater.exe' +] + +def is_excluded(path): + for excluded in excluded_paths: + + if path.startswith(excluded): + return True + return False + +def file_hash(path): + h = hashlib.sha1() + with open(path, 'rb') as f: + while chunk := f.read(8192): + h.update(chunk) + return h.hexdigest() + +def generate_manifest(directory): + manifest = {} + for root, dirs, files in os.walk(directory): + for file in files: + file_path = os.path.join(root, file) + relative_path = os.path.relpath(file_path, directory).replace("\\", "/") + + if is_excluded(relative_path): + continue + + hash_value = file_hash(file_path) + manifest[relative_path] = hash_value + return manifest + +def save_manifest(manifest, output_path): + with open(output_path, 'w') as f: + json.dump(manifest, f, indent=4) + print(f"Manifest saved to {output_path}") + +def main(): + parser = argparse.ArgumentParser(description="Generate manifest.json with SHA1 hashes.") + parser.add_argument('directory', type=str, help="Folder to scan") + parser.add_argument('--output', type=str, help="Optional output path for manifest.json") + args = parser.parse_args() + + directory = args.directory + if not os.path.isdir(directory): + print(f"Directory '{directory}' does not exist.") + return + + output_path = args.output if args.output else os.path.join(directory, 'manifest.json') + print(f"Scanning directory: {directory}...") + + manifest = generate_manifest(directory) + save_manifest(manifest, output_path) + +if __name__ == "__main__": + main() diff --git a/MIT-LICENSE.txt b/MIT-LICENSE.txt new file mode 100644 index 0000000..173554d --- /dev/null +++ b/MIT-LICENSE.txt @@ -0,0 +1,12 @@ +The MIT License +Version N/A +SPDX short identifier: MIT +Open Source Initiative Approved License + +Copyright 2025 Radiant Code + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a1b0b92 --- /dev/null +++ b/README.md @@ -0,0 +1,166 @@ +# Updater System + +**A simple and customizable update system** for Windows applications. +Supports update checking via a remote `manifest.json` hosted on an HTTP or HTTPS server. + +--- + +## Features +- Automatic checking and downloading of updated files +- GUI built with wxPython +- File integrity verification using SHA-1 +- Easy integration into any existing application + +--- + +## Requirements +- Python 3.8 or higher +- `wxPython` library (`pip install wxPython`) +- Server access to host `manifest.json` and application files over HTTP or HTTPS + +--- + +## How It Works +1. The application provides the updater with the base server URL. +2. The updater Reads `manifest.json` and checks which files need updating. +3. If updates are available, users can choose which files to update. +4. Updated files are downloaded and the main application is relaunched. + +--- + +## Setup + +### 1. Set `MAIN_APP` +At the top of your `updater.py`, configure: + +```python +MAIN_APP = "YourApp.exe" +``` +> `MAIN_APP` defines the executable file that will be relaunched after a successful update. + +--- + +### 2. Prepare Your Server +Host the following: +- `manifest.json` (contains file listings and their SHA-1 hashes) +- All application files that may require updating + +Ensure files are accessible via URLs, e.g., `https://yourdomain.com/yourapp/manifest.json`. + +--- + +### 3. Generate `manifest.json` +Use the provided `generator.py` tool. + +#### Usage: +```bash +python3 generator.py +``` +Example: +```bash +python3 generator.py my_app_folder +``` +This command scans all files inside the specified folder and generates a `manifest.json`. + +**Important Notes:** +- Some files are automatically excluded, such as: + - `manifest.json` + - `Updater.exe` +- You can modify the `excluded_paths` list in `generator.py` to customize exclusions. + +--- + +### 4. Compile the Updater +It is **strongly recommended** to compile `updater.py` into an executable (.exe) for better usability and portability. + +You can use any Python-to-EXE compiler, such as: +- [Nuitka](https://nuitka.net/) +- [PyInstaller](https://pyinstaller.org/) +- [cx_Freeze](https://anthony-tuininga.github.io/cx_Freeze/) + +Example compilation using PyInstaller: +```bash +pyinstaller --onefile updater.py +``` + +--- + +## Integrating the Updater into Your Application + +You can easily trigger the updater using the following pattern: + +```python +import wx +import os +import threading +import subprocess + +class MainFrame(wx.Frame): + def __init__(self, parent, title): + super().__init__(parent, title=title, size=(300, 200)) + panel = wx.Panel(self) + + menubar = wx.MenuBar() + + file_menu = wx.Menu() + run_updater_item = file_menu.Append(wx.ID_ANY, "Run Updater\tCtrl+U") + exit_item = file_menu.Append(wx.ID_EXIT, "Exit\tCtrl+Q") + menubar.Append(file_menu, "File") + + self.SetMenuBar(menubar) + + # Event bindings + self.Bind(wx.EVT_MENU, self.onRunUpdater, run_updater_item) + self.Bind(wx.EVT_MENU, self.onExit, exit_item) + + def run_updater_thread(self, updater_path, updater_key): + """Launch the updater in a separate thread.""" + if os.path.exists(updater_path): + try: + subprocess.Popen([updater_path, updater_key], shell=False) + except Exception as e: + print(f"Error running updater: {e}") + else: + print("Updater executable not found.") + + def onRunUpdater(self, event): + """Handler for running the updater.""" + updater_path = os.path.join(os.getcwd(), "Updater.exe") + updater_key = "https://yourdomain.com/yourapp/" + threading.Thread(target=self.run_updater_thread, args=(updater_path, updater_key)).start() + + def onExit(self, event): + """Exit the application.""" + self.Close() + +if __name__ == "__main__": + app = wx.App(False) + frame = MainFrame(None, "My App") + frame.Show() + app.MainLoop() +``` + +--- + +## Example Directory Structure on your server-site +``` +/your-app/ + Updater.exe + YourApp.exe + manifest.json + (other file/folderss...) +``` + +--- + +## Best Practices +- Always compile the updater script to `.exe` format to ensure a smooth user experience. +- Sign your executables if possible to avoid Windows SmartScreen warnings. +- Regularly update your `manifest.json` whenever files are changed. + +--- + +## License +This project is licensed under the MIT License. +You are free to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the software. +See the MIT-LICENSE.txt file for more details. \ No newline at end of file diff --git a/Updater.py b/Updater.py new file mode 100644 index 0000000..aeca5cc --- /dev/null +++ b/Updater.py @@ -0,0 +1,255 @@ +import os +import sys +import urllib.request +import urllib.error +import hashlib +import json +import wx +import subprocess +import time +import psutil + +MAIN_APP = "YourApp.exe" + +def file_hash(path): + """ + Calculate and return the SHA-1 hash of the specified file. + """ + h = hashlib.sha1() + with open(path, 'rb') as f: + while chunk := f.read(8192): + h.update(chunk) + return h.hexdigest() + +def load_remote_manifest(): + """ + Load and return the remote manifest file as a JSON object. + Exit the program if any network or HTTP error occurs. + """ + try: + with urllib.request.urlopen(MANIFEST_URL) as response: + if response.status == 404: + raise Exception(f"Manifest file not found: {MANIFEST_URL}") + return json.load(response) + except urllib.error.HTTPError as e: + if e.code == 404: + wx.MessageBox(f"Error 404: Manifest not found at {MANIFEST_URL}", "File Not Found", wx.OK | wx.ICON_ERROR) + else: + wx.MessageBox(f"HTTP error: {e.code} - {e.reason}", "HTTP Error", wx.OK | wx.ICON_ERROR) + sys.exit(0) + except urllib.error.URLError as e: + wx.MessageBox(f"Network error: {str(e)}\nPlease check your internet connection and try again.", "Network Error", wx.OK | wx.ICON_ERROR) + sys.exit(0) + except Exception as e: + wx.MessageBox(f"An error occurred: {str(e)}", "Error", wx.OK | wx.ICON_ERROR) + sys.exit(0) + +def download_file(filename, on_progress=None): + """ + Download a file from the remote URL. + Optionally updates a progress callback. + """ + url = BASE_DOWNLOAD_URL + filename + try: + with urllib.request.urlopen(url) as response: + total_size = int(response.headers.get("Content-Length", 0)) + downloaded = 0 + with open(filename, 'wb') as out_file: + while True: + chunk = response.read(8192) + if not chunk: + break + out_file.write(chunk) + downloaded += len(chunk) + if on_progress and total_size > 0: + percent = int(downloaded / total_size * 100) + on_progress(percent) + except urllib.error.URLError as e: + wx.MessageBox(f"Network error: {str(e)}\nPlease check your internet connection and try again.", "Network Error", wx.OK | wx.ICON_ERROR) + sys.exit(0) + except Exception as e: + wx.MessageBox(f"Download error: {str(e)}", "Download Error", wx.OK | wx.ICON_ERROR) + sys.exit(0) + +def terminate_main_app(): + """ + Terminates the main application process if it is currently running. + """ + try: + for proc in psutil.process_iter(['pid', 'name']): + if proc.info['name'] and proc.info['name'].lower() == MAIN_APP.lower(): + try: + proc.terminate() + proc.wait(timeout=5) + except psutil.NoSuchProcess: + pass + except Exception as e: + wx.MessageBox(f"Failed to terminate {MAIN_APP}: {str(e)}", "Error", wx.OK | wx.ICON_ERROR) + sys.exit(0) + except Exception as e: + wx.MessageBox(f"Error while checking for {MAIN_APP}: {str(e)}", "Error", wx.OK | wx.ICON_ERROR) + sys.exit(0) + +class NoUpdateDialog(wx.Dialog): + """ + Dialog shown when no update is available. + """ + def __init__(self, parent): + super().__init__(parent, title="Updater", size=(300, 120)) + panel = wx.Panel(self) + vbox = wx.BoxSizer(wx.VERTICAL) + label = wx.TextCtrl(panel, + value="No updates available. Come back again later!", + style=wx.TE_READONLY | wx.BORDER_NONE + ) + label.SetBackgroundColour(panel.GetBackgroundColour()) + label.SetFont(wx.SystemSettings.GetFont(wx.SYS_DEFAULT_GUI_FONT)) + label.SetName("Update info") + vbox.Add(label, 0, wx.ALL | wx.EXPAND, 15) + close_btn = wx.Button(panel, label="Close") + close_btn.Bind(wx.EVT_BUTTON, self.on_close) + vbox.Add(close_btn, 0, wx.ALL | wx.CENTER, 10) + panel.SetSizer(vbox) + + def on_close(self, event): + """ + Closes the dialog when the button is clicked. + """ + self.Close() + +def get_remote_file_size(filename): + """ + Returns the size of a remote file in bytes. + """ + url = BASE_DOWNLOAD_URL + filename + with urllib.request.urlopen(url) as response: + return int(response.headers.get("Content-Length", 0)) + +class UpdateDialog(wx.Dialog): + """ + Dialog that displays changelog and allows user to choose files to update. + """ + def __init__(self, parent, changelog, files): + super().__init__(parent, title="Update Available", size=(550, 600)) + panel = wx.Panel(self) + vbox = wx.BoxSizer(wx.VERTICAL) + + changelog_area = wx.TextCtrl(panel, value=changelog, style=wx.TE_READONLY | wx.TE_MULTILINE, size=(500, 200)) + vbox.Add(wx.StaticText(panel, label="Changelog:"), 0, wx.ALL, 10) + vbox.Add(changelog_area, 0, wx.ALL, 10) + + vbox.Add(wx.StaticText(panel, label="Select files to update:"), 0, wx.ALL, 10) + + filelist_box = wx.BoxSizer(wx.VERTICAL) + self.update_checkboxes = [] + + for file in files: + size = self.format_size(get_remote_file_size(file)) + checkbox = wx.CheckBox(panel, label=file) + checkbox.SetValue(True) + item_hbox = wx.BoxSizer(wx.HORIZONTAL) + item_hbox.Add(checkbox, 0, wx.ALIGN_CENTER_VERTICAL) + item_hbox.Add(wx.StaticText(panel, label=size), 1, wx.ALIGN_CENTER_VERTICAL | wx.LEFT, 10) + filelist_box.Add(item_hbox, 0, wx.ALL, 5) + self.update_checkboxes.append((checkbox, file)) + + vbox.Add(filelist_box, 1, wx.EXPAND | wx.ALL, 10) + + hbox = wx.BoxSizer(wx.HORIZONTAL) + update_button = wx.Button(panel, label="&Update Now") + update_button.Bind(wx.EVT_BUTTON, self.on_update_files) + hbox.Add(update_button, 0, wx.ALL, 5) + + cancel_button = wx.Button(panel, label="&Cancel") + cancel_button.Bind(wx.EVT_BUTTON, self.on_cancel) + hbox.Add(cancel_button, 0, wx.ALL, 5) + + vbox.Add(hbox, 0, wx.ALIGN_CENTER) + + self.progress = wx.Gauge(panel, range=100, size=(500, 20)) + vbox.Add(wx.StaticText(panel, label="Progress:"), 0, wx.LEFT | wx.TOP, 10) + vbox.Add(self.progress, 0, wx.ALL | wx.EXPAND, 10) + panel.SetSizer(vbox) + + def format_size(self, size): + """ + Convert a size in bytes to a human-readable string. + """ + for unit in ['B', 'KB', 'MB', 'GB']: + if size < 1024: + return f"{size:.1f} {unit}" + size /= 1024 + return f"{size:.1f} TB" + + def on_update_files(self, event): + """ + Download selected update files and relaunch the main application. + """ + terminate_main_app() + try: + for checkbox, filename in self.update_checkboxes: + if checkbox.GetValue(): + download_file(filename, on_progress=self.progress.SetValue) + self.progress.SetValue(0) + self.Close() + os.startfile(MAIN_APP) + sys.exit(0) + except Exception as e: + wx.MessageBox(f"Update failed:\n{str(e)}", "Update Error", wx.OK | wx.ICON_ERROR) + + def on_cancel(self, event): + """ + Cancel the update and exit the application. + """ + self.Close() + sys.exit(0) + +def set_base_urls(base_url): + """ + Set global URLs used for manifest, changelog, and downloads. + """ + global BASE_URL, MANIFEST_URL, CHANGELOG_URL, BASE_DOWNLOAD_URL + BASE_URL = base_url.rstrip('/') + '/' + MANIFEST_URL = BASE_URL + "manifest.json" + CHANGELOG_URL = BASE_URL + "changelog.txt" + BASE_DOWNLOAD_URL = BASE_URL + +def main(): + """ + Entry point for the updater. + Checks for updates and launches the appropriate dialog. + """ + if len(sys.argv) < 2: + print("Usage: python3 updater.py ") + sys.exit(0) + + set_base_urls(sys.argv[1]) + + try: + app = wx.App(False) + manifest = load_remote_manifest() + manifest.pop("manifest.json", None) + + to_update = [ + fname for fname, remote_hash in manifest.items() + if not os.path.exists(fname) or file_hash(fname) != remote_hash + ] + + if not to_update: + dialog = NoUpdateDialog(None) + dialog.ShowModal() + dialog.Destroy() + sys.exit(0) + else: + with urllib.request.urlopen(CHANGELOG_URL) as res: + changelog = res.read().decode() + dialog = UpdateDialog(None, changelog, to_update) + dialog.ShowModal() + + app.MainLoop() + + except Exception as e: + print(f"Updater Error: {e}") + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..80d1938 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +wxPython +psutil