diff --git a/.github/workflows/release-workflow.yml b/.github/workflows/release-workflow.yml index ad65dd2..576eb83 100644 --- a/.github/workflows/release-workflow.yml +++ b/.github/workflows/release-workflow.yml @@ -7,7 +7,7 @@ on: push: branches: [ "main" ] paths-ignore: - - '**/README.md' + - '**/**.md' jobs: build: @@ -20,21 +20,34 @@ jobs: strategy: matrix: - os: [ self-hosted, macos ] - arch: [ 'X64', 'ARM64' ] +# os: [ windows-latest ] +# arch: [ 'X86' ] + os: [ self-hosted, macos, windows ] + arch: [ 'X64', 'ARM64', 'X86' ] steps: +# - name: Checkout feature/windows branch +# uses: actions/checkout@v3 +# with: +# ref: feature/windows + - uses: actions/checkout@v3 - name: Set up Python 3.10.11 uses: actions/setup-python@v3 with: python-version: "3.10.11" + - name: Install dependencies in Windows + run: | + python -m pip install --upgrade pip + pip install -r .\windows\requirements.txt + - name: Install dependencies run: | python -m pip install --upgrade pip pip install py2app if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + if: matrix.os == 'macos' || matrix.os == 'self-hosted' - name: Generate app for intel architecture run: | @@ -48,6 +61,11 @@ jobs: mv dist dist-arm64 if: matrix.os == 'self-hosted' && matrix.arch == 'ARM64' + - name: Generate app for windows + run: | + python .\windows\setup.py bdist_msi + mv dist dist-windows + - name: Convert dist-intel to DMG run: | hdiutil create -volname "Hamster" -srcfolder dist-intel -ov -format UDZO dist-intel/${{ env.INTEL_DMG }} @@ -72,6 +90,12 @@ jobs: path: dist-arm64/Hamster-darwin.dmg if: matrix.os == 'self-hosted' && matrix.arch == 'ARM64' + - name: Upload windows app + uses: actions/upload-artifact@v2 + with: + name: Hamster-windows-x86_64 + path: dist-windows/Hamster-*.msi + - name: GitHub Tag uses: mathieudutour/github-tag-action@v6.1 with: @@ -98,4 +122,15 @@ jobs: generate_release_notes: true tag_name: ${{ env.RELEASE_TAG }} files: | - dist-intel/${{ env.INTEL_DMG }} \ No newline at end of file + dist-intel/${{ env.INTEL_DMG }} + + - name: Release Windows + uses: softprops/action-gh-release@v1 + if: github.ref == 'refs/heads/main' + with: + token: ${{ secrets.HAMSTER_APP_RELEASE_SECRET }} + body: 'Release for commit ${{ github.sha }}' + generate_release_notes: true + tag_name: ${{ env.RELEASE_TAG }} + files: | + dist-windows/Hamster-*.msi \ No newline at end of file diff --git a/windows/README.md b/windows/README.md new file mode 100644 index 0000000..17ece7b --- /dev/null +++ b/windows/README.md @@ -0,0 +1,17 @@ +# Build instructions for Windows +## Prerequisites +- Python + +## Build + +```powershell +cd windows +python .\setup.py bdist_msi +``` + +## Launch + +```powershell +cd windows\dist +.\Hamster-{version}-win64.msi +``` \ No newline at end of file diff --git a/windows/__init__.py b/windows/__init__.py new file mode 100644 index 0000000..b77b1f2 --- /dev/null +++ b/windows/__init__.py @@ -0,0 +1 @@ +__VERSION__ = "0.0.1" diff --git a/windows/config.py b/windows/config.py new file mode 100644 index 0000000..cccd249 --- /dev/null +++ b/windows/config.py @@ -0,0 +1,50 @@ +import re + +from collections import OrderedDict +from pathlib import Path +# from windows import __VERSION__ + + +class AppConfig: + def __init__(self): + self.app_title = 'Hamster' + self.app_title_emoji = f'🐹 {self.app_title}' + self.app_caption = 'Instantly Launch JMeter Test Plans' + self.app_caption_emoji = f'{self.app_caption} 🚀' + self.app_properties_template = "hamster_app.properties" + self.win_app_properties = self.app_properties_template.replace(".properties", ".ini") + self.home_dir = Path.home() + self.menu_items_dict = OrderedDict() + + self.jmeter_recent_files_pattern = re.compile("recent_file_.*") + self.app_version = "0.0.1" + self.buy_me_a_coffee_url = 'https://www.buymeacoffee.com/QAInsights' + self.authors = ['NaveenKumar Namachivayam', 'Leela Prasad Vadla'] + self.about_website = 'https://QAInsights.com' + + @property + def authors_str(self): + return '\n'.join(self.authors) + + @property + def about_text(self): + return f'''{self.app_title_emoji} - {self.app_caption_emoji}\n\n +Authors:\n{self.authors_str}\n\n{self.about_website} + ''' + + @property + def help_text(self): + return ''' +Hamster is a menu bar app to instantly launch JMeter test plans.\n\n +1. Configure `JMETER_HOME` by launching `Hamster > Edit JMETER_HOME`\n +2. To launch JMeter, click on `Hamster > Launch JMeter`\n +3. To launch JMeter test plans, click on `Hamster > Recent Test Plans > select the test plan`.\n +4. To view the configuration, click on `Hamster > View Config`\n +5. To restart Hamster, click on `Hamster > Refresh`\n +6. To know more about Hamster, click on `Hamster > About`\n +7. To refresh the recent test plans, click on `Hamster > Restart`\n +8. To quit Hamster, click on `Hamster > Quit`\n + ''' + + +app_config = AppConfig() diff --git a/windows/config.py~ b/windows/config.py~ new file mode 100644 index 0000000..c25bdab --- /dev/null +++ b/windows/config.py~ @@ -0,0 +1,50 @@ +import re + +from collections import OrderedDict +from pathlib import Path +from windows import __VERSION__ + + +class AppConfig: + def __init__(self): + self.app_title = 'Hamster' + self.app_title_emoji = f'🐹 {self.app_title}' + self.app_caption = 'Instantly Launch JMeter Test Plans' + self.app_caption_emoji = f'{self.app_caption} 🚀' + self.app_properties_template = "hamster_app.properties" + self.win_app_properties = self.app_properties_template.replace(".properties", ".ini") + self.home_dir = Path.home() + self.menu_items_dict = OrderedDict() + + self.jmeter_recent_files_pattern = re.compile("recent_file_.*") + self.app_version = __VERSION__ + self.buy_me_a_coffee_url = 'https://www.buymeacoffee.com/QAInsights' + self.authors = ['NaveenKumar Namachivayam', 'Leela Prasad Vadla'] + self.about_website = 'https://QAInsights.com' + + @property + def authors_str(self): + return '\n'.join(self.authors) + + @property + def about_text(self): + return f'''{self.app_title_emoji} - {self.app_caption_emoji}\n\n +Authors:\n{self.authors_str}\n\n{self.about_website} + ''' + + @property + def help_text(self): + return ''' +Hamster is a menu bar app to instantly launch JMeter test plans.\n\n +1. Configure `JMETER_HOME` by launching `Hamster > Edit JMETER_HOME`\n +2. To launch JMeter, click on `Hamster > Launch JMeter`\n +3. To launch JMeter test plans, click on `Hamster > Recent Test Plans > select the test plan`.\n +4. To view the configuration, click on `Hamster > View Config`\n +5. To restart Hamster, click on `Hamster > Refresh`\n +6. To know more about Hamster, click on `Hamster > About`\n +7. To refresh the recent test plans, click on `Hamster > Restart`\n +8. To quit Hamster, click on `Hamster > Quit`\n + ''' + + +app_config = AppConfig() diff --git a/windows/hamster.ico b/windows/hamster.ico new file mode 100644 index 0000000..a0a0d82 Binary files /dev/null and b/windows/hamster.ico differ diff --git a/windows/hamster.png b/windows/hamster.png new file mode 100644 index 0000000..a0a0d82 Binary files /dev/null and b/windows/hamster.png differ diff --git a/windows/hamster_app.properties b/windows/hamster_app.properties new file mode 100644 index 0000000..cff1b52 --- /dev/null +++ b/windows/hamster_app.properties @@ -0,0 +1,2 @@ +[JMETER] +home = C:\Tools\apache-jmeter-5.6.2 diff --git a/windows/main.py b/windows/main.py new file mode 100644 index 0000000..9acbadb --- /dev/null +++ b/windows/main.py @@ -0,0 +1,45 @@ +from PIL import Image +from pystray import MenuItem, Menu, Icon + +from config import app_config +from utils import get_recent_test_plans, create_app_data_dir, action_launch_jmeter, action_recent_test_plan, \ + action_view_config, action_edit_config, action_refresh, action_quit, action_help, action_about, action_sponsor + + +def main(): + """ + The main function of the application. It creates a system tray icon with a context menu. + The context menu includes options to launch JMeter, view recent test plans, view and edit configuration, refresh, and quit the application. + Returns: + + """ + # argv impl + image = Image.open("hamster.png") + recent_test_plans = get_recent_test_plans() + recent_test_plans_menu_items = [MenuItem(plan, action_recent_test_plan) for plan in recent_test_plans] + + app_config.menu_items_dict.update({ + "Launch JMeter": MenuItem('🚀 Launch JMeter', action_launch_jmeter), + "Recent Test Plans": MenuItem('Recent Test Plans', Menu(*recent_test_plans_menu_items)), + "Seperator01": Menu.SEPARATOR, + "View Config": MenuItem('View Config', action_view_config), + "Configure JMETER_HOME": MenuItem('Configure JMETER_HOME', action_edit_config), + "Seperator02": Menu.SEPARATOR, + "Refresh": MenuItem('Refresh', action_refresh), + "Buy me a coffee": MenuItem('☕ Buy me a coffee', action_sponsor), + "Help": MenuItem('Help', action_help), + "About": MenuItem('About', action_about), + "Quit": MenuItem('Quit', action_quit) + }) + + # Create the menu with the menu items + menu = Menu(*app_config.menu_items_dict.values()) + + # Create the icon with the menu + icon = Icon("Hamster", image, f"{app_config.app_title} - {app_config.app_caption}", menu) + icon.run() + + +if __name__ == "__main__": + create_app_data_dir() + main() diff --git a/windows/requirements.txt b/windows/requirements.txt new file mode 100644 index 0000000..4178ae9 Binary files /dev/null and b/windows/requirements.txt differ diff --git a/windows/setup.py b/windows/setup.py new file mode 100644 index 0000000..355b412 --- /dev/null +++ b/windows/setup.py @@ -0,0 +1,56 @@ +import sys +from cx_Freeze import setup, Executable +# from windows import __VERSION__ + +msi_data = { + "ProgId": [ + ("Prog.Id", None, None, "Hamster - Instantly Launch JMeter Test Plans", "IconId", None), + ], + "Icon": [ + ("IconId", r'windows\\hamster.ico'), + ], +} + +bdist_msi_options = { + "data": msi_data, + "install_icon": r'windows\\hamster.ico', + "initial_target_dir": r'[ProgramFilesFolder]\%s\%s' % ("QAInsights", "Hamster"), + "summary_data": { + "author": "QAInsights", + "comments": "Hamster - Instantly Launch JMeter Test Plans", + "keywords": "JMeter, Performance Testing, QAInsights, Apache JMeter, Hamster", + }, +} + +build_exe_options = { + "include_files": ["windows\\hamster.png", + "windows\\config.py", + "windows\\utils.py", + "windows\\hamster_app.properties", + "windows\\hamster.ico", + ], +} +base = "Win32GUI" if sys.platform == "win32" else None + +executables = [ + Executable( + "windows\\main.py", + copyright="Copyright (C) 2023 QAInsights", + base=base, + shortcut_name="Hamster", + shortcut_dir="ProgramMenuFolder", + icon="windows\\hamster.ico", + ), + ] + +setup( + name="Hamster", + version="0.0.1", + description="Hamster - Instantly Launch JMeter Test Plans 🚀", + options={ + "build_exe": build_exe_options, + "bdist_msi": bdist_msi_options, + }, + executables=executables, + packages=["windows"] +) diff --git a/windows/utils.py b/windows/utils.py new file mode 100644 index 0000000..4caa4b2 --- /dev/null +++ b/windows/utils.py @@ -0,0 +1,261 @@ +import configparser +import os +import re +import shutil +import subprocess +import time +import webbrowser +import winreg +import logging + +from collections import OrderedDict +from contextlib import suppress +from tkinter import filedialog, messagebox +from pystray import MenuItem, Menu +from config import app_config + +# Create a logger +logger = logging.getLogger(__name__) + +# Set the log level +logger.setLevel(logging.DEBUG) + +# Create a file handler +handler = logging.FileHandler(os.path.join(app_config.home_dir, 'Appdata', 'Local', 'hamster.log')) + +# Create a logging format +formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') +handler.setFormatter(formatter) + +# Add the handlers to the logger +logger.addHandler(handler) + +menu_items_dict = OrderedDict() + +config_parser = configparser.ConfigParser() +properties_folder_path = os.path.join(app_config.home_dir, 'Appdata', 'Local', app_config.app_title) +properties_file_path = os.path.join(app_config.home_dir, 'Appdata', 'Local', app_config.app_title, app_config.win_app_properties) + + +def create_app_data_dir(): + """ + Creates the app data directory + Returns: + + """ + # create app data dir in user home\appdata\local + if not os.path.exists(properties_folder_path): + os.makedirs(properties_folder_path) + # check for ini file inside the app data dir + if not os.path.exists(os.path.join(properties_folder_path, app_config.win_app_properties)): + # copy the hamster_app.properties file to the app data dir + logger.info(f'Copying { app_config.app_properties_template} to { app_config.properties_folder_path}') + + source_file = os.path.join(os.getcwd(), app_config.app_properties_template) + destination_file = os.path.join(properties_folder_path, app_config.win_app_properties) + logger.info(f'Copying {source_file} to {destination_file}') + shutil.copyfile(source_file, destination_file) + logger.info(f'Copied {source_file} to {destination_file}') + + +def update_properties(properties): + """ + Updates the properties file + Args: + properties: + + Returns: + + """ + config_parser.read(properties_file_path) + for key, value in properties.items(): + logger.info(f'Updating {key} with {value}') + config_parser['JMETER'][key] = value + + with open(properties_file_path, 'w') as config_file: + config_parser.write(config_file) + + +def read_properties(): + """ + Reads the properties file + Returns: + + """ + config_parser.read(properties_file_path) + for key, value in config_parser['JMETER'].items(): + logger.info(f'Reading {key} with {value}') + return value + + +def get_recent_test_plans(): + """ + Gets the recent test plans from the registry + Returns: + + """ + recent_test_plans = [] + key_path = r'Software\JavaSoft\Prefs\org\apache\jmeter\gui\action' + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path) as key: + index = 0 + while True: + try: + value_name, value_data, _ = winreg.EnumValue(key, index) + if re.match( app_config.jmeter_recent_files_pattern, value_name): + clean_jmeter_path = value_data.replace('///', '\\').replace('//', '\\').replace('/', '') + recent_test_plans.append(clean_jmeter_path) + index += 1 + except OSError as e: + with suppress(OSError): + winreg.CloseKey(key) + if e.errno == 259: # No more data is available + break + break + except FileNotFoundError: + logger.critical(f"Error: Registry key '{key_path}' not found.") + except Exception as e: + logger.critical(f"Error: {e}") + + return recent_test_plans + + +def launch_test_plan(test_plan=None): + """ + Launches JMeter with the selected test plan + Args: + test_plan: + """ + # launch jmeter with the test plan + jmeter_plan = f"{test_plan}" + config_parser.read(properties_file_path) + jmeter_home = config_parser['JMETER']['HOME'] + jmeter_bin = os.path.join(jmeter_home, 'bin') + jmeter_logs = os.path.join(jmeter_bin, 'jmeter.log') + jmeter_path = os.path.join(jmeter_bin, 'jmeter.bat') + if not test_plan: + subprocess.Popen([jmeter_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + else: + logger.info(f'Launching JMeter with {jmeter_path} {jmeter_logs} {jmeter_plan} ') + subprocess.Popen([jmeter_path, "-j", jmeter_logs, "-t", test_plan], stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + + +def action_launch_jmeter(icon, menu_item): + """ + Launches JMeter + Args: + icon: + menu_item: + + Returns: + + """ + launch_test_plan() + + +def action_recent_test_plan(icon, menu_item): + """ + Launches JMeter with the selected test plan + Args: + icon: + menu_item: + + Returns: + + """ + launch_test_plan(menu_item.text) + refresh_test_plans(icon) + + +def action_refresh(icon, _): + """ + Refreshes the recent test plans menu items + Args: + icon: + _: + + Returns: + + """ + refresh_test_plans(icon, 0) + + +def action_view_config(): + """ + Displays the JMeter home directory + Returns: + + """ + __jmeter_home = read_properties() + messagebox.showinfo("Hamster Config", f"JMETER_HOME: {__jmeter_home}") + + +def action_edit_config(): + """ + Opens a file dialog to select the JMeter home directory + Returns: + + """ + _default_jmeter_home = read_properties() + _jmeter_home = filedialog.askdirectory(initialdir=_default_jmeter_home, mustexist=True, + title=f"{ app_config.app_title} - Configure JMETER_HOME") + if _jmeter_home: + update_properties({'HOME': str(_jmeter_home).strip()}) + + +def action_quit(icon, _): + """ + Quits the application + Args: + icon: + _: + + Returns: + + """ + icon.stop() + + +def action_help(help_text=None): + """ + Displays the `help` dialog + Returns: + + """ + messagebox.showinfo(f"About {app_config.app_title_emoji}", message=app_config.help_text, icon='info') + + +def action_about(): + """ + Displays the `about` dialog + Returns: + + """ + messagebox.showinfo(f"About {app_config.app_title_emoji} - v{app_config.app_version}", f"{app_config.about_text}", icon='info') + + +def action_sponsor(): + """ + Opens the `buy me a coffee` link + Returns: + + """ + webbrowser.open_new_tab(app_config.buy_me_a_coffee_url) + + +def refresh_test_plans(icon, delay=5): + """ + Refreshes the recent test plans menu items + Args: + icon: + delay: + + Returns: + + """ + time.sleep(delay) + updated_test_plans = get_recent_test_plans() + refreshed_test_plans_menu = [MenuItem(plan, action_recent_test_plan) for plan in updated_test_plans] + app_config.menu_items_dict["Recent Test Plans"] = MenuItem('Recent Test Plans', Menu(*refreshed_test_plans_menu)) + icon.menu = Menu(*app_config.menu_items_dict.values())