diff --git a/.github/workflows/manual.yml b/.github/workflows/manual.yml new file mode 100644 index 0000000..3ec6f42 --- /dev/null +++ b/.github/workflows/manual.yml @@ -0,0 +1,68 @@ +name: Manual + +on: + workflow_dispatch: + inputs: + platform: + description: 'Choose the platform' + required: true + default: 'windows' + options: + - windows + - macos + +jobs: + build: + if: github.event.inputs.platform == 'windows' + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pyinstaller + + - name: Build with PyInstaller + run: | + pyinstaller --onefile --noconsole --name Baafucha --add-data "assets/icon.png:assets" --icon=assets/icon.png main.py + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: Baafucha + path: dist/Baafucha.exe + + build-macos: + if: github.event.inputs.platform == 'macos' + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12.5' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pyinstaller + + - name: Build with PyInstaller + run: | + pyinstaller --onefile --noconsole --name Baafucha --add-data "assets/icon.png:assets" --icon=assets/icon.png main.py --osx-bundle-identifier com.baafucha.app + zip -r dist/Baafucha.app.zip dist/Baafucha.app + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: Baafucha + path: dist/Baafucha.app.zip diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0524968..42792c1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,8 +36,34 @@ jobs: name: Baafucha path: dist/Baafucha.exe + build-macos: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pyinstaller + + - name: Build with PyInstaller + run: | + pyinstaller --onefile --noconsole --name Baafucha --add-data "assets/icon.png:assets" --icon=assets/icon.png main.py + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: Baafucha + path: dist/Baafucha + release: - needs: build + needs: [build, build-macos] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -71,3 +97,19 @@ jobs: asset_path: ./Baafucha.exe asset_name: Baafucha.exe asset_content_type: application/octet-stream + + - name: Download MacOS artifact + uses: actions/download-artifact@v4 + with: + name: Baafucha + path: dist/Baafucha + + - name: Upload MacOS Release Asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./Baafucha + asset_name: Baafucha + asset_content_type: application/octet-stream diff --git a/.gitignore b/.gitignore index 191e70f..f72280f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ dist/ *.spec *.pyc baafucha_config.json + +venv/ + +.DS_Store diff --git a/README.md b/README.md index eeacba8..76387fb 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Ba'afucha is a tiny program that provides an easy way to convert text between En ## Requirements -- Windows operating system +- Windows or MacOS operating system ## Installation @@ -50,7 +50,7 @@ Download the latest release from the [Releases](https://github.com/matipojo/baaf ## Usage -1. If you downloaded the release version, simply run the `Baafucha.exe` file. +1. If you downloaded the release version, simply run the `Baafucha.exe` file on Windows or `Baafucha` file on MacOS. 2. If you're running from source, run the script: @@ -86,6 +86,22 @@ To create a standalone executable for Windows: 3. Find the `Baafucha.exe` in the `dist` folder. +To create a standalone executable for MacOS: + +1. Ensure you have PyInstaller installed: + + ``` + pip install pyinstaller + ``` + +2. Run PyInstaller: + + ``` + pyinstaller --onefile --noconsole --name Baafucha baafucha.py + ``` + +3. Find the `Baafucha` in the `dist` folder. + ## Continuous Integration This project uses GitHub Actions for continuous integration. Every merge to the main branch triggers a new build and release. You can find the latest release in the [Releases](https://github.com/matipojo/baafucha/releases) section of the repository. diff --git a/core/taskbar.py b/core/taskbar.py index 7858c3e..0df7266 100644 --- a/core/taskbar.py +++ b/core/taskbar.py @@ -10,24 +10,21 @@ import ctypes import multiprocessing import time -import winreg import threading +import platform from core.tray import load_config -# Loading the library user32.dll -user32 = ctypes.windll.user32 -kernel32 = ctypes.windll.kernel32 - -# Gets the handle of the taskbar -taskbar_handle = user32.FindWindowW("Shell_TrayWnd", None) - -# Setting constants -WM_SETTINGCHANGE = 0x001A - -import winreg - -REGISTRY_PATH = r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize" -VALUE_NAME = "ColorPrevalence" +if platform.system() == "Windows": + import winreg + user32 = ctypes.windll.user32 + kernel32 = ctypes.windll.kernel32 + taskbar_handle = user32.FindWindowW("Shell_TrayWnd", None) + WM_SETTINGCHANGE = 0x001A + REGISTRY_PATH = r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize" + VALUE_NAME = "ColorPrevalence" +elif platform.system() == "Darwin": + from AppKit import NSApp, NSApplicationActivationPolicyRegular + from Foundation import NSUserDefaults def get_set_color_prevalence(set_value=None): """ @@ -35,24 +32,36 @@ def get_set_color_prevalence(set_value=None): If set_value is None, it returns the current value. If set_value is provided, it sets the new value and returns it. """ - - try: - with winreg.OpenKey(winreg.HKEY_CURRENT_USER, REGISTRY_PATH, 0, winreg.KEY_READ | winreg.KEY_WRITE) as key: - if set_value is None: - value, _ = winreg.QueryValueEx(key, VALUE_NAME) - return value - else: - winreg.SetValueEx(key, VALUE_NAME, 0, winreg.REG_DWORD, set_value) - return set_value - except Exception as e: - print(f"An error occurred with ColorPrevalence: {e}") - return None + if platform.system() == "Windows": + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, REGISTRY_PATH, 0, winreg.KEY_READ | winreg.KEY_WRITE) as key: + if set_value is None: + value, _ = winreg.QueryValueEx(key, VALUE_NAME) + return value + else: + winreg.SetValueEx(key, VALUE_NAME, 0, winreg.REG_DWORD, set_value) + return set_value + except Exception as e: + print(f"An error occurred with ColorPrevalence: {e}") + return None + elif platform.system() == "Darwin": + defaults = NSUserDefaults.standardUserDefaults() + if set_value is None: + return defaults.integerForKey("AppleInterfaceStyleSwitchesAutomatically") + else: + defaults.setInteger_forKey_(set_value, "AppleInterfaceStyleSwitchesAutomatically") + return set_value def refresh_taskbar(): """ Refreshes the taskbar by sending a settings change notification to the taskbar. """ - user32.SendMessageW(taskbar_handle, WM_SETTINGCHANGE, 0, "ImmersiveColorSet") + if platform.system() == "Windows": + user32.SendMessageW(taskbar_handle, WM_SETTINGCHANGE, 0, "ImmersiveColorSet") + elif platform.system() == "Darwin": + app = NSApp() + app.setActivationPolicy_(NSApplicationActivationPolicyRegular) + app.activateIgnoringOtherApps_(True) def set_color_prevalence(value): """ @@ -62,19 +71,15 @@ def set_color_prevalence(value): refresh_taskbar() def get_current_input_language(): - # Get the foreground window - hwnd = user32.GetForegroundWindow() - - # Get the thread of the foreground window - thread_id = user32.GetWindowThreadProcessId(hwnd, 0) - - # Get the keyboard layout of the thread - layout_id = user32.GetKeyboardLayout(thread_id) - - # Extract the language ID from the keyboard layout - language_id = layout_id & 0xFFFF - - return language_id + if platform.system() == "Windows": + hwnd = user32.GetForegroundWindow() + thread_id = user32.GetWindowThreadProcessId(hwnd, 0) + layout_id = user32.GetKeyboardLayout(thread_id) + language_id = layout_id & 0xFFFF + return language_id + elif platform.system() == "Darwin": + languages = NSUserDefaults.standardUserDefaults().stringForKey_("AppleLanguages") + return languages[0] if languages else None language_monitor_thread = None language_monitor_stop_event = None @@ -108,32 +113,21 @@ def on_config_change(new_config): if not language_monitor_thread: start_language_monitor() -# The main process of the program def monitor_language(stop_event): - # Stores the last language ID last_layout_id = get_current_input_language() - - # Set the initial color value new_value = 0 if last_layout_id == kernel32.GetUserDefaultUILanguage() else 1 - set_color_prevalence( new_value ) + set_color_prevalence(new_value) - # Main loop to check language changes while not stop_event.is_set(): layout_id = get_current_input_language() - - # If a language change is detected, changes the color value and refreshes the taskbar if layout_id != last_layout_id: last_layout_id = layout_id - if layout_id == kernel32.GetUserDefaultUILanguage(): set_color_prevalence(0) else: set_color_prevalence(1) print("Language change detected to language ID:", layout_id) - - # Waits 0.2 seconds before next test time.sleep(0.2) -# Running the main program if __name__ == "__main__": main() diff --git a/core/tray.py b/core/tray.py index 8b3f3a2..41c38f4 100644 --- a/core/tray.py +++ b/core/tray.py @@ -1,29 +1,43 @@ import sys import os -import winreg +import platform import pystray from PIL import Image import json +import pathlib + +if platform.system() == "Windows": + import winreg +elif platform.system() == "Darwin": + from AppKit import NSBundle + from Foundation import NSUserDefaults APP_NAME = "Baafucha" CONFIG_DIR = os.path.join(os.path.expanduser('~'), '.baafucha') CONFIG_FILE = os.path.join(CONFIG_DIR, 'config.json') def get_startup_key(): - return winreg.OpenKey( - winreg.HKEY_CURRENT_USER, - r"Software\Microsoft\Windows\CurrentVersion\Run", - 0, - winreg.KEY_ALL_ACCESS - ) + if platform.system() == "Windows": + return winreg.OpenKey( + winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion\Run", + 0, + winreg.KEY_ALL_ACCESS + ) + elif platform.system() == "Darwin": + return 'com.baafucha.app' def is_startup_enabled(): - try: - with get_startup_key() as key: - winreg.QueryValueEx(key, APP_NAME) - return True - except WindowsError: - return False + if platform.system() == "Windows": + try: + with get_startup_key() as key: + winreg.QueryValueEx(key, APP_NAME) + return True + except WindowsError: + return False + elif platform.system() == "Darwin": + defaults = NSUserDefaults.standardUserDefaults() + return defaults.boolForKey_(get_startup_key()) def load_config(): if not os.path.exists(CONFIG_DIR): @@ -54,19 +68,54 @@ def save_config(config): json.dump(config, f) def enable_startup(): - try: - with get_startup_key() as key: - app_path = sys.executable - winreg.SetValueEx(key, APP_NAME, 0, winreg.REG_SZ, app_path) - except WindowsError as e: - print(f"Error enabling startup: {e}") + if platform.system() == "Windows": + try: + with get_startup_key() as key: + app_path = sys.executable + winreg.SetValueEx(key, APP_NAME, 0, winreg.REG_SZ, app_path) + except WindowsError as e: + print(f"Error enabling startup: {e}") + elif platform.system() == "Darwin": + # App location thanks to: https://github.com/mrjohannchang/macos-application-location.py + p: pathlib.Path = pathlib.Path.cwd() / sys.argv[0] + + if '.app/Contents/MacOS/' in str(p): + p = p.parent.parent.parent + + plist = f""" + + + + + Label + {get_startup_key()} + ProgramArguments + + {p} + RunAtLoad + + + + """ + launchd_path = os.path.expanduser(f"~/Library/LaunchAgents/{get_startup_key()}.plist") + with open(launchd_path, 'w') as f: + f.write(plist) + os.system(f"launchctl load {launchd_path}") + NSUserDefaults.standardUserDefaults().setBool_forKey_(True, get_startup_key()) def disable_startup(): - try: - with get_startup_key() as key: - winreg.DeleteValue(key, APP_NAME) - except WindowsError as e: - print(f"Error disabling startup: {e}") + if platform.system() == "Windows": + try: + with get_startup_key() as key: + winreg.DeleteValue(key, APP_NAME) + except WindowsError as e: + print(f"Error disabling startup: {e}") + elif platform.system() == "Darwin": + launchd_path = os.path.expanduser(f"~/Library/LaunchAgents/{get_startup_key()}.plist") + os.system(f"launchctl unload {launchd_path}") + if os.path.exists(launchd_path): + os.remove(launchd_path) + NSUserDefaults.standardUserDefaults().setBool_forKey_(False, get_startup_key()) def toggle_startup(icon, item): config = load_config() @@ -86,7 +135,7 @@ def on_quit(icon, stop_listener_func): class SystemTrayApp: def __init__(self, stop_listener_func, config_callback): self.icon_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'assets', 'icon.png') - + self.menu = pystray.Menu( pystray.MenuItem('Change taskbar color', toggle_taskbar_color_config, checked=lambda _: is_taskbar_color_enabled()), pystray.MenuItem('Load on startup', toggle_startup, checked=lambda _: is_startup_enabled()), diff --git a/main.py b/main.py index 2250e58..9a65e58 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,5 @@ -import keyboard import pyperclip -from pynput import keyboard as pynput_keyboard +from pynput import keyboard import time from PIL import Image import threading @@ -9,6 +8,10 @@ from core.taskbar import start_language_monitor from core.taskbar import stop_language_monitor from core.taskbar import on_config_change +import platform + +# Determine the modifier key based on the operating system +MODIFIER_KEY = keyboard.Key.ctrl if platform.system() == "Windows" else keyboard.Key.cmd # Keyboard mapping for English to Hebrew and vice versa en_to_he = { @@ -33,11 +36,17 @@ def auto_convert(select_all=False): original_clipboard = pyperclip.paste() if select_all: - keyboard.press_and_release('ctrl+a') + keyboard_controller.press(MODIFIER_KEY) + keyboard_controller.press('a') + keyboard_controller.release('a') + keyboard_controller.release(MODIFIER_KEY) time.sleep(0.1) # Try to copy selected text - keyboard.press_and_release('ctrl+c') + keyboard_controller.press(MODIFIER_KEY) + keyboard_controller.press('c') + keyboard_controller.release('c') + keyboard_controller.release(MODIFIER_KEY) time.sleep(0.1) selected_text = pyperclip.paste() @@ -54,10 +63,13 @@ def auto_convert(select_all=False): converted_text = convert_text(selected_text, he_to_en) else: converted_text = convert_text(selected_text, en_to_he) - + # Copy converted text to clipboard and paste pyperclip.copy(converted_text) - keyboard.press_and_release('ctrl+v') + keyboard_controller.press(MODIFIER_KEY) + keyboard_controller.press('v') + keyboard_controller.release('v') + keyboard_controller.release(MODIFIER_KEY) # Restore original clipboard content time.sleep(0.1) @@ -66,17 +78,31 @@ def auto_convert(select_all=False): def on_key_press(key): """Handle key press events.""" try: - if key == pynput_keyboard.Key.f8: - # Check if shift is pressed - if keyboard.is_pressed('control'): + if key == keyboard.Key.f8: + if MODIFIER_KEY in pressed_keys: auto_convert(select_all=True) else: auto_convert(select_all=False) except AttributeError: pass -# Create keyboard listener -listener = pynput_keyboard.Listener(on_press=on_key_press) +def on_key_release(key): + """Handle key release events.""" + try: + if key in pressed_keys: + pressed_keys.remove(key) + except KeyError: + pass + +# Create keyboard controller and listener +keyboard_controller = keyboard.Controller() +pressed_keys = set() + +def on_press(key): + pressed_keys.add(key) + on_key_press(key) + +listener = keyboard.Listener(on_press=on_press, on_release=on_key_release) def stop_listener(): """Stop the keyboard listener.""" @@ -90,10 +116,10 @@ def stop_listener(): print("Baafucha is running.") print("Press F8 to convert selected text between English and Hebrew.") - print("Press Ctrl+F8 to select all text and convert it.") + print(f"Press {MODIFIER_KEY}+F8 to select all text and convert it.") start_language_monitor() - + # Run system tray icon tray = SystemTrayApp(stop_listener, config_callback=on_config_change) tray.run() diff --git a/requirements.txt b/requirements.txt index 46c9f00..76225b5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ -keyboard==0.13.5 pyperclip==1.8.2 -pynput==1.7.6 -pystray==0.19.4 -Pillow==9.5.0 +pynput==1.7.7 +pystray==0.19.5 +Pillow==11.0.0