├── overai ├── about │ ├── version.txt │ ├── author.txt │ └── description.txt ├── logo │ ├── icon.icns │ ├── logo_black.png │ ├── logo_white.png │ └── icon.iconset │ │ ├── 128.png │ │ ├── 16.png │ │ ├── 256.png │ │ ├── 32.png │ │ ├── 512.png │ │ ├── 64.png │ │ └── 1024.png ├── __main__.py ├── __init__.py ├── constants.py ├── main.py ├── health_checks.py ├── launcher.py ├── listener.py └── app.py ├── OverAI.py ├── MANIFEST.in ├── requirements.txt ├── .gitignore ├── setup.py └── README.md /overai/about/version.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 1.0.0 -------------------------------------------------------------------------------- /OverAI.py: -------------------------------------------------------------------------------- 1 | from overai.main import main 2 | 3 | if __name__ == '__main__': 4 | main() 5 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include overai * 2 | global-exclude *.so *.dylib 3 | exclude .git .gitignore 4 | -------------------------------------------------------------------------------- /overai/logo/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/N-Saipraveen/overai-mac/HEAD/overai/logo/icon.icns -------------------------------------------------------------------------------- /overai/logo/logo_black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/N-Saipraveen/overai-mac/HEAD/overai/logo/logo_black.png -------------------------------------------------------------------------------- /overai/logo/logo_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/N-Saipraveen/overai-mac/HEAD/overai/logo/logo_white.png -------------------------------------------------------------------------------- /overai/logo/icon.iconset/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/N-Saipraveen/overai-mac/HEAD/overai/logo/icon.iconset/128.png -------------------------------------------------------------------------------- /overai/logo/icon.iconset/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/N-Saipraveen/overai-mac/HEAD/overai/logo/icon.iconset/16.png -------------------------------------------------------------------------------- /overai/logo/icon.iconset/256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/N-Saipraveen/overai-mac/HEAD/overai/logo/icon.iconset/256.png -------------------------------------------------------------------------------- /overai/logo/icon.iconset/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/N-Saipraveen/overai-mac/HEAD/overai/logo/icon.iconset/32.png -------------------------------------------------------------------------------- /overai/logo/icon.iconset/512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/N-Saipraveen/overai-mac/HEAD/overai/logo/icon.iconset/512.png -------------------------------------------------------------------------------- /overai/logo/icon.iconset/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/N-Saipraveen/overai-mac/HEAD/overai/logo/icon.iconset/64.png -------------------------------------------------------------------------------- /overai/logo/icon.iconset/1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/N-Saipraveen/overai-mac/HEAD/overai/logo/icon.iconset/1024.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyobjc 2 | pyobjc-framework-Quartz 3 | pyobjc-framework-WebKit 4 | setuptools 5 | py2app ; platform_system == "Darwin" -------------------------------------------------------------------------------- /overai/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | OverAI command-line entrypoint. 3 | """ 4 | from .main import main 5 | 6 | if __name__ == "__main__": 7 | main() 8 | -------------------------------------------------------------------------------- /overai/about/author.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | Praveen 4 | 7th Semester Student, VIT-AP University 5 | Specialization: DevOps & Cloud 6 | 7 | GitHub: https://github.com/N-Saipraveen 8 | LinkedIn: https://www.linkedin.com/in/sai-praveen-26b186253/ 9 | 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Virtualenv 2 | .venv/ 3 | 4 | # Python caches 5 | __pycache__/ 6 | *.py[cod] 7 | 8 | # Build artifacts 9 | build/ 10 | dist/ 11 | *.app 12 | *.dmg 13 | *.spec 14 | 15 | # macOS hidden files 16 | .DS_Store 17 | 18 | # Logs 19 | *.log -------------------------------------------------------------------------------- /overai/about/description.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | OverAI is a sleek, always-on-top macOS overlay that brings powerful AI chat services directly to your desktop. 4 | Seamlessly switch between Grok, ChatGPT, DeepSeek, or any custom endpoint with the built-in selector, and interact via text or voice thanks to integrated microphone support. 5 | Adjust the transparency, hide or show with ⌘+G, and enjoy a frameless window designed to blend into your workflow—over any app, at any time. OverAI handles all permissions automatically, giving you a polished, ready-to-use AI companion in seconds. -------------------------------------------------------------------------------- /overai/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | OverAI - A macOS overlay app for OverAI. 3 | 4 | Every AI in a single window. 5 | 6 | -- Sai Praveen 7 | """ 8 | 9 | import os 10 | 11 | DIRECTORY = os.path.dirname(os.path.abspath(__file__)) 12 | ABOUT_DIR = os.path.join(DIRECTORY, "about") 13 | 14 | def _read_about_file(fname, default=""): 15 | try: 16 | with open(os.path.join(ABOUT_DIR, fname)) as f: 17 | return f.read().strip() 18 | except Exception: 19 | return default 20 | 21 | __version__ = _read_about_file("version.txt", "0.0.1") 22 | __author__ = _read_about_file("author.txt", "Sai Praveen") 23 | 24 | __all__ = ["main"] 25 | 26 | from .main import main 27 | 28 | # Only import if you want "from overai import main" to work. -------------------------------------------------------------------------------- /overai/constants.py: -------------------------------------------------------------------------------- 1 | # Apple libraries 2 | from Quartz import ( 3 | kCGEventFlagMaskAlternate, 4 | kCGEventFlagMaskCommand, 5 | kCGEventFlagMaskControl, 6 | kCGEventFlagMaskShift, 7 | ) 8 | 9 | # Main settings and constants for OverAI 10 | WEBSITE = "https://www.grok.com" 11 | LOGO_WHITE_PATH = "logo/logo_white.png" 12 | LOGO_BLACK_PATH = "logo/logo_black.png" 13 | FRAME_SAVE_NAME = "OverAIWindowFrame" 14 | APP_TITLE = "OverAI" 15 | PERMISSION_CHECK_EXIT = 1 16 | CORNER_RADIUS = 15.0 17 | DRAG_AREA_HEIGHT = 30 18 | STATUS_ITEM_CONTEXT = 1 19 | 20 | # Hotkey config: Command+G (key 5 is "g" in macOS virtual keycodes) 21 | LAUNCHER_TRIGGER_MASK = kCGEventFlagMaskCommand 22 | LAUNCHER_TRIGGER = { 23 | "flags": kCGEventFlagMaskCommand, 24 | "key": 5 # 'g' key 25 | } 26 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # setup.py 2 | from setuptools import setup 3 | 4 | APP = ["OverAI.py"] 5 | DATA_FILES = [] 6 | OPTIONS = { 7 | # Bundle your package directory so imports “just work” 8 | "packages": ["overai"], 9 | "includes": [], 10 | # GUI app (no console window) 11 | "argv_emulation": False, 12 | # Optional: your .icns icon 13 | "iconfile": "overai/logo/icon.icns", 14 | # Allow microphone & Accessibility prompts by embedding Info.plist keys: 15 | "plist": { 16 | "NSMicrophoneUsageDescription": "OverAI needs your mic for voice input.", 17 | "NSAppleEventsUsageDescription": "OverAI needs accessibility permission for hotkeys." 18 | }, 19 | } 20 | 21 | setup( 22 | app=APP, 23 | data_files=DATA_FILES, 24 | options={"py2app": OPTIONS}, 25 | setup_requires=["py2app"], 26 | include_package_data=True, 27 | ) -------------------------------------------------------------------------------- /overai/main.py: -------------------------------------------------------------------------------- 1 | # Python libraries 2 | import argparse 3 | import sys 4 | 5 | # Local libraries. 6 | from .constants import ( 7 | APP_TITLE, 8 | LAUNCHER_TRIGGER, 9 | LAUNCHER_TRIGGER_MASK, 10 | PERMISSION_CHECK_EXIT, 11 | ) 12 | from .app import ( 13 | AppDelegate, 14 | NSApplication 15 | ) 16 | from .launcher import ( 17 | check_permissions, 18 | ensure_accessibility_permissions, 19 | install_startup, 20 | uninstall_startup 21 | ) 22 | from .health_checks import ( 23 | health_check_decorator 24 | ) 25 | 26 | # Main executable for running the application from the command line. 27 | @health_check_decorator 28 | def main(): 29 | parser = argparse.ArgumentParser( 30 | description=f"macOS {APP_TITLE} Overlay App - Dedicated window that can be summoned and dismissed with your keyboard shortcut." 31 | ) 32 | parser.add_argument( 33 | "--install-startup", 34 | action="store_true", 35 | help=f"Install {APP_TITLE} to run at login", 36 | ) 37 | parser.add_argument( 38 | "--uninstall-startup", 39 | action="store_true", 40 | help=f"Uninstall {APP_TITLE} from running at login", 41 | ) 42 | parser.add_argument( 43 | "--check-permissions", 44 | action="store_true", 45 | help="Check Accessibility permissions only" 46 | ) 47 | args = parser.parse_args() 48 | 49 | if args.install_startup: 50 | install_startup() 51 | return 52 | 53 | if args.uninstall_startup: 54 | uninstall_startup() 55 | return 56 | 57 | if args.check_permissions: 58 | is_trusted = check_permissions(ask=False) 59 | print("Permissions granted:", is_trusted) 60 | sys.exit(0 if is_trusted else PERMISSION_CHECK_EXIT) 61 | 62 | # Check permissions (make request to user) when launching, but proceed regardless. 63 | check_permissions() 64 | # # Ensure permissions before proceeding 65 | # ensure_accessibility_permissions() 66 | 67 | # Default behavior: run the app and inform user of startup options 68 | print() 69 | print(f"Starting macOS {APP_TITLE} overlay.") 70 | print() 71 | print(f"To run at login, use: {APP_TITLE.lower()} --install-startup") 72 | print(f"To remove from login, use: {APP_TITLE.lower()} --uninstall-startup") 73 | print() 74 | app = NSApplication.sharedApplication() 75 | delegate = AppDelegate.alloc().init() 76 | app.setDelegate_(delegate) 77 | app.run() 78 | 79 | if __name__ == "__main__": 80 | # Execute the decorated main function. 81 | main() -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🧠 OverAI — Stealth AI Overlay for macOS 2 | 3 | **OverAI** is a local-first, always-on-top AI overlay for macOS that gives you seamless, private access to your favorite LLMs like ChatGPT, Grok, Perplexity, Claude, and Gemini. 4 | 5 | > **No servers. No tracking. No nonsense. Just powerful AI where you need it.** 6 | 7 | --- 8 | 9 | ## 🚀 Quick Start 10 | 11 | ```bash 12 | git clone https://github.com/N-Saipraveen/overai-mac.git 13 | cd overai-mac 14 | 15 | python3 -m venv .venv 16 | source .venv/bin/activate 17 | 18 | pip install --upgrade pip 19 | pip install -r requirements.txt 20 | 21 | python overai.py 22 | ``` 23 | 24 | 📅 OverAI launches instantly and stays ready on your screen. 25 | 26 | --- 27 | 28 | ## 🛠 Configuration 29 | 30 | - **Hotkey**: Default is `⌘ + G` — customizable in `config.json` 31 | - **AI Endpoints**: Choose ChatGPT, Grok, DeepSeek, etc. from the dropdown 32 | - **Auto-hide on Meetings**: Detects Zoom, Teams, Webex and hides the overlay 33 | 34 | --- 35 | 36 | ## 🔐 Permissions Required 37 | 38 | When you launch OverAI for the first time, macOS will request: 39 | 40 | - 🎙️ **Microphone Access** — to capture voice commands 41 | - ⌨️ **Accessibility Access** — to enable the global hotkey (⌘+G) 42 | 43 | You can manage these anytime from: **System Settings → Privacy & Security** 44 | 45 | --- 46 | 47 | ## ✨ Features 48 | 49 | | Feature | Description | 50 | | -------------------------- | ------------------------------------------------- | 51 | | 🪟 Frameless Overlay | Stays always on top, clean and distraction-free | 52 | | 🧠 Multi-AI Support | Easily switch between ChatGPT, Grok, Claude, etc. | 53 | | 🎙️ Voice & Text Input | Speak or type your prompt directly | 54 | | 🎛️ Transparency Control | Adjust overlay opacity to your preference | 55 | | 🎹 Hotkey Toggle | `⌘+G` or any custom key combo to toggle overlay | 56 | | 🕵️ Hidden from Recordings | Invisible in screen sharing and screen recordings | 57 | | 🖥️ Lightweight + Local | No lag, no cloud storage, no external servers | 58 | 59 | --- 60 | 61 | ## 💻 Tech Stack 62 | 63 | - Python 3.10+ 64 | - AppKit, Quartz, WebKit 65 | - PyObjC 66 | - SpeechRecognition 67 | 68 | --- 69 | 70 | ## 🤝 Contributing 71 | 72 | Contributions welcome! 73 | 74 | - Fork the repo 75 | - Create a feature branch 76 | - Submit a pull request 77 | 78 | --- 79 | 80 | ## 📜 License 81 | 82 | MIT License. See [LICENSE](LICENSE) for details. 83 | 84 | --- 85 | 86 | ## ⭐ Like it? 87 | 88 | If this project helped you, **please star the repo 🌟** — it really helps! 89 | 90 | -------------------------------------------------------------------------------- /overai/health_checks.py: -------------------------------------------------------------------------------- 1 | """ 2 | health_checks.py 3 | Health and crash-loop protection for OverAI macOS overlay app. 4 | """ 5 | 6 | import os 7 | import sys 8 | import time 9 | import tempfile 10 | import traceback 11 | import functools 12 | import platform 13 | import objc 14 | from pathlib import Path 15 | 16 | # Get a path for logging errors that is persistent. 17 | def get_log_dir(): 18 | # Set a persistent log directory in the user's home folder 19 | log_dir = Path.home() / "Library" / "Logs" / "overai" 20 | # Create the directory if it doesn't exist 21 | log_dir.mkdir(parents=True, exist_ok=True) 22 | return log_dir 23 | 24 | # Settings for crash loop detection. 25 | LOG_DIR = get_log_dir() 26 | LOG_PATH = LOG_DIR / "overai_error_log.txt" 27 | CRASH_COUNTER_FILE = LOG_DIR / "overai_crash_counter.txt" 28 | CRASH_THRESHOLD = 3 # Maximum allowed crashes within the time window. 29 | CRASH_TIME_WINDOW = 60 # Time window in seconds. 30 | 31 | # Returns a string containing the macOS version, Python version, and PyObjC version. 32 | def get_system_info(): 33 | macos_version = platform.mac_ver()[0] 34 | python_version = platform.python_version() 35 | pyobjc_version = getattr(objc, '__version__', 'unknown') 36 | info = ( 37 | "\n" 38 | "System Information:\n" 39 | f" macOS version: {macos_version}\n" 40 | f" Python version: {python_version}\n" 41 | f" PyObjC version: {pyobjc_version}\n" 42 | ) 43 | return info 44 | 45 | # Reads and updates the crash counter; exits if a crash loop is detected. 46 | def check_crash_loop(): 47 | current_time = time.time() 48 | count = 0 49 | last_time = 0 50 | # Read previous crash info if it exists. 51 | if os.path.exists(CRASH_COUNTER_FILE): 52 | try: 53 | with open(CRASH_COUNTER_FILE, "r") as f: 54 | line = f.read().strip() 55 | if line: 56 | last_time_str, count_str = line.split(",") 57 | last_time = float(last_time_str) 58 | count = int(count_str) 59 | except Exception: 60 | # On any error, reset the counter. 61 | count = 0 62 | # If the last crash was within the time window, increment; otherwise, reset. 63 | if current_time - last_time < CRASH_TIME_WINDOW: 64 | count += 1 65 | else: 66 | count = 1 67 | # Write the updated crash info back to the file. 68 | try: 69 | with open(CRASH_COUNTER_FILE, "w") as f: 70 | f.write(f"{current_time},{count}") 71 | except Exception as e: 72 | print("Warning: Could not update crash counter file:", e) 73 | 74 | # If the count exceeds the threshold, abort further restarts. 75 | if count > CRASH_THRESHOLD: 76 | print("ERROR: Crash loop detected (more than {} crashes within {} seconds). Crash counter file (for reference) at:\n {}\n\nAborting further restarts. To resume attempts to launch, delete the counter file with:\n rm {}\n\nError log (most recent) at:\n {}".format( 77 | CRASH_THRESHOLD, 78 | CRASH_TIME_WINDOW, 79 | CRASH_COUNTER_FILE, 80 | CRASH_COUNTER_FILE, 81 | LOG_PATH 82 | )) 83 | sys.exit(1) 84 | 85 | # Resets the crash counter after a successful run. 86 | def reset_crash_counter(): 87 | if os.path.exists(CRASH_COUNTER_FILE): 88 | try: 89 | os.remove(CRASH_COUNTER_FILE) 90 | except Exception as e: 91 | print("Warning: Could not reset crash counter file:", e) 92 | 93 | # Decorator to wrap the main function with crash loop detection and error logging. 94 | # If the wrapped function raises an exception, the error is logged (with system info) 95 | # and printed to the terminal before exiting. 96 | def health_check_decorator(func): 97 | @functools.wraps(func) 98 | def wrapper(*args, **kwargs): 99 | check_crash_loop() 100 | try: 101 | result = func(*args, **kwargs) 102 | reset_crash_counter() 103 | print("SUCCESS") 104 | return result 105 | except Exception: 106 | system_info = get_system_info() 107 | error_trace = traceback.format_exc() 108 | with open(LOG_PATH, "w") as log_file: 109 | log_file.write("An unhandled exception occurred:\n") 110 | log_file.write(system_info) 111 | log_file.write(error_trace) 112 | print("ERROR: Application failed to start properly. Details:") 113 | print(system_info) 114 | print(error_trace) 115 | print(f"Error log saved at: {LOG_PATH}", flush=True) 116 | sys.exit(1) 117 | return wrapper -------------------------------------------------------------------------------- /overai/launcher.py: -------------------------------------------------------------------------------- 1 | """ 2 | launcher.py 3 | Startup/permission utilities for OverAI - the universal Mac AI overlay. 4 | """ 5 | 6 | # Python libraries. 7 | import getpass 8 | import os 9 | import subprocess 10 | import sys 11 | import time 12 | from pathlib import Path 13 | 14 | # Apple libraries. 15 | import plistlib 16 | from Foundation import NSDictionary 17 | from ApplicationServices import AXIsProcessTrustedWithOptions, kAXTrustedCheckOptionPrompt 18 | 19 | # Local libraries 20 | from .constants import APP_TITLE 21 | from .health_checks import reset_crash_counter 22 | 23 | # --- App Path Utilities --- 24 | 25 | def get_executable(): 26 | """ 27 | Return the appropriate executable/program_args list for LaunchAgent or CLI usage. 28 | """ 29 | if getattr(sys, "frozen", False): # Running from a py2app bundle 30 | assert (".app" in sys.argv[0]), f"Expected .app in sys.argv[0], got {sys.argv[0]}" 31 | # Find the .app bundle path 32 | app_path = sys.argv[0] 33 | while not app_path.endswith(".app"): 34 | app_path = os.path.dirname(app_path) 35 | # Main binary inside .app/Contents/MacOS/OverAI 36 | executable = os.path.join(app_path, "Contents", "MacOS", APP_TITLE) 37 | program_args = [executable] 38 | else: # Running from source (pip install or python -m ...) 39 | program_args = [sys.executable, "-m", APP_TITLE.lower()] 40 | return program_args 41 | 42 | # --- LaunchAgent Install/Uninstall --- 43 | 44 | def install_startup(): 45 | """ 46 | Install OverAI as a LaunchAgent (run at login) for this user. 47 | """ 48 | username = getpass.getuser() 49 | program_args = get_executable() 50 | plist = { 51 | "Label": f"com.{username}.{APP_TITLE.lower()}", 52 | "ProgramArguments": program_args, 53 | "RunAtLoad": True, 54 | "KeepAlive": True, 55 | } 56 | launch_agents_dir = Path.home() / "Library" / "LaunchAgents" 57 | launch_agents_dir.mkdir(parents=True, exist_ok=True) 58 | plist_path = launch_agents_dir / f"com.{username}.{APP_TITLE.lower()}.plist" 59 | with open(plist_path, "wb") as f: 60 | plistlib.dump(plist, f) 61 | result = os.system(f"launchctl load {plist_path}") 62 | if result != 0: 63 | print(f"Failed to load LaunchAgent. Exit code: {result}") 64 | return False 65 | print(f"✅ OverAI installed as a startup app (LaunchAgent created at {plist_path}).") 66 | print(f"To uninstall, run: {APP_TITLE.lower()} --uninstall-startup") 67 | return True 68 | 69 | def uninstall_startup(): 70 | """ 71 | Remove OverAI LaunchAgent from user login items. 72 | """ 73 | username = getpass.getuser() 74 | launch_agents_dir = Path.home() / "Library" / "LaunchAgents" 75 | plist_path = launch_agents_dir / f"com.{username}.{APP_TITLE.lower()}.plist" 76 | if plist_path.exists(): 77 | try: 78 | os.system(f"launchctl unload {plist_path}") 79 | print(f"OverAI LaunchAgent unloaded.") 80 | except Exception as e: 81 | print(f"Failed to unload LaunchAgent. Exception:\n{e}") 82 | print(f"LaunchAgent file removed: {plist_path}") 83 | os.remove(plist_path) 84 | return True 85 | else: 86 | print("No OverAI LaunchAgent found. Nothing to uninstall.") 87 | return False 88 | 89 | # --- Accessibility Permissions --- 90 | 91 | def check_permissions(ask=True): 92 | """ 93 | Check (and optionally prompt for) macOS Accessibility permissions. 94 | """ 95 | print(f"\nChecking macOS Accessibility permissions for OverAI. If not already granted, a dialog may appear.\n", flush=True) 96 | options = NSDictionary.dictionaryWithObject_forKey_( 97 | True, kAXTrustedCheckOptionPrompt 98 | ) 99 | is_trusted = AXIsProcessTrustedWithOptions(options if ask else None) 100 | return is_trusted 101 | 102 | def get_updated_permission_status(): 103 | """ 104 | Check current permission status by spawning a subprocess. 105 | """ 106 | result = subprocess.run( 107 | get_executable() + ["--check-permissions"], 108 | capture_output=True, 109 | text=True 110 | ) 111 | return result.returncode == 0 112 | 113 | def wait_for_permissions(max_wait_sec=60, wait_interval_sec=5): 114 | """ 115 | Poll for permissions for up to max_wait_sec seconds. 116 | """ 117 | elapsed = 0 118 | while elapsed < max_wait_sec: 119 | if get_updated_permission_status(): 120 | return True 121 | time.sleep(wait_interval_sec) 122 | elapsed += wait_interval_sec 123 | reset_crash_counter() 124 | return False 125 | 126 | def ensure_accessibility_permissions(): 127 | """ 128 | Ensure Accessibility permissions are granted; otherwise, exit/uninstall. 129 | """ 130 | if check_permissions(): # Initial call to prompt 131 | return 132 | if wait_for_permissions(): 133 | print("Permissions granted! Exiting for auto-restart...") 134 | return 135 | else: 136 | print("Permissions NOT granted within time limit. Uninstalling OverAI.") 137 | uninstall_startup() -------------------------------------------------------------------------------- /overai/listener.py: -------------------------------------------------------------------------------- 1 | """ 2 | listener.py 3 | 4 | Handles global keyboard listening and dynamic hotkey customization for OverAI overlay. 5 | Allows users to set their own keyboard shortcut (trigger) for toggling the OverAI window. 6 | """ 7 | 8 | # Python libraries 9 | import json 10 | import time 11 | from pathlib import Path 12 | 13 | # Apple libraries 14 | from AppKit import ( 15 | NSColor, 16 | NSEvent, 17 | NSFont, 18 | NSKeyDown, 19 | NSMakeRect, 20 | NSRoundedBezelStyle, 21 | NSTextAlignmentCenter, 22 | NSTextField, 23 | NSView, 24 | NSButton, 25 | ) 26 | from Quartz import ( 27 | CGEventCreateKeyboardEvent, 28 | CGEventKeyboardGetUnicodeString, 29 | CGEventGetFlags, 30 | CGEventGetIntegerValueField, 31 | kCGEventKeyDown, 32 | kCGKeyboardEventKeycode, 33 | NSEvent, 34 | NSAlternateKeyMask, 35 | NSCommandKeyMask, 36 | NSControlKeyMask, 37 | NSShiftKeyMask, 38 | ) 39 | 40 | # Local libraries 41 | from .constants import LAUNCHER_TRIGGER, LAUNCHER_TRIGGER_MASK 42 | from .health_checks import LOG_DIR 43 | 44 | # File for storing the custom trigger 45 | TRIGGER_FILE = LOG_DIR / "custom_trigger.json" 46 | SPECIAL_KEY_NAMES = { 47 | 49: "Space", 36: "Return", 53: "Escape", 48 | 122: "F1", 120: "F2", 99: "F3", 118: "F4", 49 | 96: "F5", 97: "F6", 98: "F7", 100: "F8", 50 | 101: "F9", 109: "F10", 103: "F11", 111: "F12", 51 | 123: "Left Arrow", 124: "Right Arrow", 52 | 125: "Down Arrow", 126: "Up Arrow" 53 | } 54 | handle_new_trigger = None 55 | 56 | def load_custom_launcher_trigger(): 57 | """Load custom launcher trigger from JSON file if it exists.""" 58 | if TRIGGER_FILE.exists(): 59 | try: 60 | with open(TRIGGER_FILE, "r") as f: 61 | data = json.load(f) 62 | launcher_trigger = {"flags": data["flags"], "key": data["key"]} 63 | print(f"Overwriting default with a custom OverAI launch shortcut:\n {launcher_trigger}", flush=True) 64 | print(f"To return to default, delete this file:\n {TRIGGER_FILE}", flush=True) 65 | LAUNCHER_TRIGGER.update(launcher_trigger) 66 | except (json.JSONDecodeError, KeyError) as e: 67 | print(f"[OverAI] Failed to load custom trigger: {e}. Using default trigger.", flush=True) 68 | 69 | def set_custom_launcher_trigger(app): 70 | """Show UI to set a new global hotkey trigger.""" 71 | app.showWindow_(None) 72 | print("Setting new OverAI launch shortcut. (Press keys or click Cancel to abort)", flush=True) 73 | # Disable the current trigger 74 | LAUNCHER_TRIGGER["flags"] = None 75 | LAUNCHER_TRIGGER["key"] = None 76 | # Get the content view bounds 77 | content_view = app.window.contentView() 78 | content_bounds = content_view.bounds() 79 | # Create the overlay view to shade the main application 80 | overlay_view = NSView.alloc().initWithFrame_(content_bounds) 81 | overlay_view.setWantsLayer_(True) 82 | overlay_view.layer().setBackgroundColor_(NSColor.colorWithWhite_alpha_(0.0, 0.5).CGColor()) # Semi-transparent black 83 | 84 | # Define container dimensions 85 | container_width = 400 86 | container_height = 200 87 | container_x = (content_bounds.size.width - container_width) / 2 88 | container_y = (content_bounds.size.height - container_height) / 2 89 | container_frame = NSMakeRect(container_x, container_y, container_width, container_height) 90 | container_view = NSView.alloc().initWithFrame_(container_frame) 91 | container_view.setWantsLayer_(True) 92 | # Slightly blueish background for OverAI branding (can tweak) 93 | container_view.layer().setBackgroundColor_(NSColor.colorWithCalibratedRed_green_blue_alpha_(0.12, 0.20, 0.32, 0.97).CGColor()) 94 | container_view.layer().setCornerRadius_(12) # Rounded corners 95 | 96 | # Message label 97 | message_label_frame = NSMakeRect(0, container_height - 60, container_width, 40) 98 | message_label = NSTextField.alloc().initWithFrame_(message_label_frame) 99 | message_label.setStringValue_("Press the new shortcut key combination now.") 100 | message_label.setBezeled_(False) 101 | message_label.setDrawsBackground_(False) 102 | message_label.setEditable_(False) 103 | message_label.setSelectable_(False) 104 | message_label.setAlignment_(NSTextAlignmentCenter) 105 | message_label.setFont_(NSFont.boldSystemFontOfSize_(17)) 106 | 107 | # Trigger display container (light background) 108 | trigger_display_container_frame = NSMakeRect(60, container_height - 110, 280, 38) 109 | trigger_display_container = NSView.alloc().initWithFrame_(trigger_display_container_frame) 110 | trigger_display_container.setWantsLayer_(True) 111 | trigger_display_container.layer().setBackgroundColor_(NSColor.lightGrayColor().CGColor()) 112 | trigger_display_container.layer().setCornerRadius_(7) 113 | 114 | # Trigger display (shows "Waiting..." or key combo) 115 | trigger_display_frame = NSMakeRect(0, -10, 280, 38) 116 | trigger_display = NSTextField.alloc().initWithFrame_(trigger_display_frame) 117 | trigger_display.setStringValue_("Waiting for key press...") 118 | trigger_display.setBezeled_(False) 119 | trigger_display.setDrawsBackground_(False) 120 | trigger_display.setEditable_(False) 121 | trigger_display.setSelectable_(False) 122 | trigger_display.setAlignment_(NSTextAlignmentCenter) 123 | trigger_display.setFont_(NSFont.systemFontOfSize_(16)) 124 | 125 | # Cancel Button 126 | cancel_button_frame = NSMakeRect(container_width / 2 - 40, 20, 80, 32) 127 | cancel_button = NSButton.alloc().initWithFrame_(cancel_button_frame) 128 | cancel_button.setTitle_("Cancel") 129 | cancel_button.setBezelStyle_(NSRoundedBezelStyle) 130 | def cancel_action(sender): 131 | print("Cancelled custom shortcut selection.", flush=True) 132 | overlay_view.removeFromSuperview() 133 | global handle_new_trigger 134 | handle_new_trigger = None 135 | app.showWindow_(None) 136 | cancel_button.setTarget_(cancel_action) 137 | cancel_button.setAction_("callWithSender:") 138 | 139 | # Assemble hierarchy 140 | trigger_display_container.addSubview_(trigger_display) 141 | container_view.addSubview_(message_label) 142 | container_view.addSubview_(trigger_display_container) 143 | container_view.addSubview_(cancel_button) 144 | overlay_view.addSubview_(container_view) 145 | content_view.addSubview_(overlay_view) 146 | 147 | # Handler for new trigger 148 | def custom_handle_new_trigger(event, flags, keycode): 149 | launcher_trigger = {"flags": flags, "key": keycode} 150 | with open(TRIGGER_FILE, "w") as f: 151 | json.dump(launcher_trigger, f) 152 | LAUNCHER_TRIGGER.update(launcher_trigger) 153 | trigger_str = get_trigger_string(event, flags, keycode) 154 | print("New OverAI launch shortcut set:", flush=True) 155 | print(f" {launcher_trigger}", flush=True) 156 | print(f" {trigger_str}", flush=True) 157 | trigger_display.setStringValue_(trigger_str) 158 | # Remove overlay after 2 seconds 159 | overlay_view.performSelector_withObject_afterDelay_("removeFromSuperview", None, 2.0) 160 | global handle_new_trigger 161 | handle_new_trigger = None 162 | app.showWindow_(None) 163 | return None 164 | 165 | # Set the global handler 166 | global handle_new_trigger 167 | handle_new_trigger = custom_handle_new_trigger 168 | 169 | def get_modifier_names(flags): 170 | """Helper to get modifier names as list.""" 171 | modifier_names = [] 172 | if flags & NSShiftKeyMask: 173 | modifier_names.append("Shift") 174 | if flags & NSControlKeyMask: 175 | modifier_names.append("Control") 176 | if flags & NSAlternateKeyMask: 177 | modifier_names.append("Option") 178 | if flags & NSCommandKeyMask: 179 | modifier_names.append("Command") 180 | return modifier_names 181 | 182 | def get_trigger_string(event, flags, keycode): 183 | """Return a human-readable string for the trigger.""" 184 | modifier_names = get_modifier_names(flags) 185 | if keycode in SPECIAL_KEY_NAMES: 186 | key_name = SPECIAL_KEY_NAMES[keycode] 187 | else: 188 | key_name = NSEvent.eventWithCGEvent_(event).characters() 189 | return " + ".join(modifier_names + [key_name]) if modifier_names else key_name 190 | 191 | def global_show_hide_listener(app): 192 | """Global event listener for showing/hiding OverAI and for new trigger assignment.""" 193 | def listener(proxy, event_type, event, refcon): 194 | if event_type == kCGEventKeyDown: 195 | keycode = CGEventGetIntegerValueField(event, kCGKeyboardEventKeycode) 196 | flags = CGEventGetFlags(event) 197 | # Debug print for key/flag detection 198 | # print("Key event detected:", keycode, "flags:", flags, "trigger:", LAUNCHER_TRIGGER) 199 | if (None in set(LAUNCHER_TRIGGER.values())) and handle_new_trigger: 200 | print(" Received keys, establishing new shortcut..", flush=True) 201 | handle_new_trigger(event, flags, keycode) 202 | return None 203 | # Use bitwise AND for modifier matching (CMD + G etc) 204 | elif keycode == LAUNCHER_TRIGGER["key"] and (flags & LAUNCHER_TRIGGER["flags"]) == LAUNCHER_TRIGGER["flags"]: 205 | if app.window.isKeyWindow(): 206 | app.hideWindow_(None) 207 | else: 208 | app.showWindow_(None) 209 | return None 210 | return event 211 | return listener -------------------------------------------------------------------------------- /overai/app.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Python libraries 4 | import os 5 | import sys 6 | import signal 7 | import json 8 | from pathlib import Path 9 | 10 | import objc 11 | from AppKit import * 12 | from AppKit import NSWindowSharingNone 13 | from WebKit import * 14 | from Quartz import * 15 | from AVFoundation import AVCaptureDevice, AVMediaTypeAudio 16 | # Accessibility prompt import with fallback 17 | try: 18 | from Quartz import AXIsProcessTrustedWithOptions, kAXTrustedCheckOptionPrompt 19 | except ImportError: 20 | try: 21 | from Quartz.CoreGraphics import AXIsProcessTrustedWithOptions, kAXTrustedCheckOptionPrompt 22 | except ImportError: 23 | AXIsProcessTrustedWithOptions = None 24 | kAXTrustedCheckOptionPrompt = None 25 | from Foundation import NSObject, NSURL, NSURLRequest, NSDate 26 | from CoreFoundation import kCFRunLoopCommonModes 27 | 28 | # Local libraries 29 | from .constants import ( 30 | APP_TITLE, 31 | CORNER_RADIUS, 32 | DRAG_AREA_HEIGHT, 33 | LOGO_BLACK_PATH, 34 | LOGO_WHITE_PATH, 35 | FRAME_SAVE_NAME, 36 | STATUS_ITEM_CONTEXT, 37 | WEBSITE, 38 | ) 39 | from .launcher import ( 40 | install_startup, 41 | uninstall_startup, 42 | ) 43 | from .listener import ( 44 | global_show_hide_listener, 45 | load_custom_launcher_trigger, 46 | set_custom_launcher_trigger, 47 | ) 48 | from .health_checks import LOG_DIR 49 | 50 | CONFIG_FILE = LOG_DIR / "config.json" 51 | DEFAULT_SERVICE = "Grok" 52 | 53 | # Add AI service endpoints 54 | AI_SERVICES = { 55 | "Grok": "https://grok.com", 56 | "Gemini": "https://gemini.google.com", 57 | "ChatGPT": "https://chat.openai.com", 58 | "Claude": "https://claude.ai/chat", 59 | "DeepSeek": "https://chat.deepseek.com", 60 | } 61 | 62 | # Custom window (contains entire application). 63 | class AppWindow(NSWindow): 64 | # Explicitly allow key window status 65 | def canBecomeKeyWindow(self): 66 | return True 67 | 68 | # Required to capture "Command+..." sequences. 69 | def keyDown_(self, event): 70 | delegate = self.delegate() 71 | if delegate and hasattr(delegate, 'keyDown_'): 72 | delegate.keyDown_(event) 73 | else: 74 | # Fallback to default handling 75 | objc.super(AppWindow, self).keyDown_(event) 76 | 77 | 78 | # Custom view (contains click-and-drag area on top sliver of overlay). 79 | class DragArea(NSView): 80 | def initWithFrame_(self, frame): 81 | objc.super(DragArea, self).initWithFrame_(frame) 82 | self.setWantsLayer_(True) 83 | return self 84 | 85 | # Used to update top-bar background to (roughly) match app color. 86 | def setBackgroundColor_(self, color): 87 | self.layer().setBackgroundColor_(color.CGColor()) 88 | 89 | # Used to capture the click-and-drag event. 90 | def mouseDown_(self, event): 91 | self.window().performWindowDragWithEvent_(event) 92 | 93 | 94 | # The main delegate for running the overlay app. 95 | class AppDelegate(NSObject): 96 | def load_default_service(self): 97 | """Load the default service from the config file.""" 98 | if CONFIG_FILE.exists(): 99 | try: 100 | with open(CONFIG_FILE, "r") as f: 101 | config = json.load(f) 102 | return config.get("default_service", DEFAULT_SERVICE) 103 | except Exception as e: 104 | print(f"Failed to load config: {e}") 105 | return DEFAULT_SERVICE 106 | 107 | def save_default_service(self, service_name): 108 | """Save the default service to the config file.""" 109 | config = {} 110 | if CONFIG_FILE.exists(): 111 | try: 112 | with open(CONFIG_FILE, "r") as f: 113 | config = json.load(f) 114 | except Exception: 115 | pass 116 | 117 | config["default_service"] = service_name 118 | try: 119 | with open(CONFIG_FILE, "w") as f: 120 | json.dump(config, f) 121 | print(f"Saved default service: {service_name}") 122 | except Exception as e: 123 | print(f"Failed to save config: {e}") 124 | 125 | def remove_default_service_config(self): 126 | """Remove the default service configuration.""" 127 | if CONFIG_FILE.exists(): 128 | try: 129 | with open(CONFIG_FILE, "r") as f: 130 | config = json.load(f) 131 | if "default_service" in config: 132 | del config["default_service"] 133 | with open(CONFIG_FILE, "w") as f: 134 | json.dump(config, f) 135 | print("Removed default service preference") 136 | except Exception as e: 137 | print(f"Failed to update config: {e}") 138 | 139 | # The main application setup. 140 | def applicationDidFinishLaunching_(self, notification): 141 | # Placeholders for event tap and its run loop source 142 | self.eventTap = None 143 | self.eventTapSource = None 144 | # Run as accessory app 145 | NSApp.setActivationPolicy_(NSApplicationActivationPolicyAccessory) 146 | # Create a borderless, floating, resizable window 147 | screen = NSScreen.mainScreen() 148 | screen_rect = screen.visibleFrame() 149 | full_screen_rect = screen.frame() 150 | window_width = 550 151 | window_height = 580 152 | 153 | # Center horizontally on the physical screen 154 | x_pos = full_screen_rect.origin.x + (full_screen_rect.size.width - window_width) / 2 155 | y_pos = screen_rect.origin.y + 20 # 20px padding from bottom/dock 156 | 157 | self.window = AppWindow.alloc().initWithContentRect_styleMask_backing_defer_( 158 | NSMakeRect(x_pos, y_pos, window_width, window_height), 159 | NSBorderlessWindowMask | NSResizableWindowMask, 160 | NSBackingStoreBuffered, 161 | False 162 | ) 163 | self.window.setLevel_(NSFloatingWindowLevel) 164 | self.window.setCollectionBehavior_( 165 | NSWindowCollectionBehaviorCanJoinAllSpaces 166 | | NSWindowCollectionBehaviorStationary 167 | ) 168 | # Save the last position and size 169 | self.window.setFrameAutosaveName_(FRAME_SAVE_NAME) 170 | # Create the webview for the main application. 171 | config = WKWebViewConfiguration.alloc().init() 172 | config.preferences().setJavaScriptCanOpenWindowsAutomatically_(True) 173 | # Initialize the WebView with a frame 174 | self.webview = WKWebView.alloc().initWithFrame_configuration_( 175 | ((0, 0), (800, 600)), # Frame: origin (0,0), size (800x600) 176 | config 177 | ) 178 | self.webview.setUIDelegate_(self) 179 | self.webview.setAutoresizingMask_(NSViewWidthSizable | NSViewHeightSizable) # Resizes with window 180 | # Set a custom user agent 181 | safari_user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15" 182 | self.webview.setCustomUserAgent_(safari_user_agent) 183 | # Make window transparent so that the corners can be rounded 184 | self.window.setOpaque_(False) 185 | self.window.setBackgroundColor_(NSColor.clearColor()) 186 | # Set slight initial transparency 187 | self.window.setAlphaValue_(0.80) 188 | # Prevent overlay from appearing in screenshots or screen recordings 189 | self.window.setSharingType_(NSWindowSharingNone) 190 | # Set up content view with rounded corners 191 | content_view = NSView.alloc().initWithFrame_(self.window.contentView().bounds()) 192 | content_view.setWantsLayer_(True) 193 | content_view.layer().setCornerRadius_(CORNER_RADIUS) 194 | content_view.layer().setBackgroundColor_(NSColor.whiteColor().CGColor()) 195 | self.window.setContentView_(content_view) 196 | # Set up drag area (top sliver, full width) 197 | content_bounds = content_view.bounds() 198 | self.drag_area = DragArea.alloc().initWithFrame_( 199 | NSMakeRect(0, content_bounds.size.height - DRAG_AREA_HEIGHT, content_bounds.size.width, DRAG_AREA_HEIGHT) 200 | ) 201 | content_view.addSubview_(self.drag_area) 202 | # Add close button to the drag area 203 | close_button = NSButton.alloc().initWithFrame_(NSMakeRect(5, 5, 20, 20)) 204 | close_button.setBordered_(False) 205 | close_button.setImage_(NSImage.imageWithSystemSymbolName_accessibilityDescription_("xmark.circle.fill", None)) 206 | close_button.setTarget_(self) 207 | close_button.setAction_("hideWindow:") 208 | self.drag_area.addSubview_(close_button) 209 | # Add AI service selector dropdown 210 | self.ai_selector = NSPopUpButton.alloc().initWithFrame_(NSMakeRect(40, 5, 150, 20)) 211 | for name in AI_SERVICES.keys(): 212 | self.ai_selector.addItemWithTitle_(name) 213 | 214 | # Set initial selection based on saved default 215 | default_service = self.load_default_service() 216 | if default_service in AI_SERVICES: 217 | self.ai_selector.selectItemWithTitle_(default_service) 218 | else: 219 | self.ai_selector.selectItemWithTitle_(DEFAULT_SERVICE) 220 | 221 | self.ai_selector.setTarget_(self) 222 | self.ai_selector.setAction_("aiServiceChanged:") 223 | self.drag_area.addSubview_(self.ai_selector) 224 | # Anchor selector to the left edge when drag area resizes 225 | self.ai_selector.setAutoresizingMask_(NSViewMaxXMargin) 226 | 227 | # Add "Default" checkbox next to selector 228 | self.default_checkbox = NSButton.alloc().initWithFrame_(NSMakeRect(200, 5, 60, 20)) 229 | self.default_checkbox.setButtonType_(NSSwitchButton) 230 | self.default_checkbox.setTitle_("Default") 231 | self.default_checkbox.setTarget_(self) 232 | self.default_checkbox.setAction_("toggleDefault:") 233 | self.updateDefaultCheckboxState() 234 | self.drag_area.addSubview_(self.default_checkbox) 235 | self.default_checkbox.setAutoresizingMask_(NSViewMaxXMargin) 236 | 237 | # Add quit button (X) to the far right 238 | quit_btn = NSButton.alloc().initWithFrame_(NSMakeRect(content_bounds.size.width - 25, 5, 20, 20)) 239 | quit_btn.setBordered_(False) 240 | quit_btn.setImage_(NSImage.imageWithSystemSymbolName_accessibilityDescription_("xmark", None)) 241 | quit_btn.setTarget_(self) 242 | quit_btn.setAction_("quitApp:") 243 | self.drag_area.addSubview_(quit_btn) 244 | # Anchor quit button to the right edge 245 | quit_btn.setAutoresizingMask_(NSViewMinXMargin) 246 | 247 | # Add transparency control buttons 248 | decrease_btn = NSButton.alloc().initWithFrame_(NSMakeRect(content_bounds.size.width - 85, 5, 20, 20)) 249 | decrease_btn.setBordered_(False) 250 | decrease_btn.setImage_(NSImage.imageWithSystemSymbolName_accessibilityDescription_("minus.circle.fill", None)) 251 | decrease_btn.setTarget_(self) 252 | decrease_btn.setAction_("decreaseTransparency:") 253 | self.drag_area.addSubview_(decrease_btn) 254 | # Anchor decrease button to the right edge 255 | decrease_btn.setAutoresizingMask_(NSViewMinXMargin) 256 | 257 | increase_btn = NSButton.alloc().initWithFrame_(NSMakeRect(content_bounds.size.width - 55, 5, 20, 20)) 258 | increase_btn.setBordered_(False) 259 | increase_btn.setImage_(NSImage.imageWithSystemSymbolName_accessibilityDescription_("plus.circle.fill", None)) 260 | increase_btn.setTarget_(self) 261 | increase_btn.setAction_("increaseTransparency:") 262 | self.drag_area.addSubview_(increase_btn) 263 | # Anchor increase button to the right edge 264 | increase_btn.setAutoresizingMask_(NSViewMinXMargin) 265 | # Update the webview sizinug and insert it below drag area. 266 | content_view.addSubview_(self.webview) 267 | self.webview.setFrame_(NSMakeRect(0, 0, content_bounds.size.width, content_bounds.size.height - DRAG_AREA_HEIGHT)) 268 | # Contat the target website. 269 | selected_service = self.ai_selector.titleOfSelectedItem() 270 | url = NSURL.URLWithString_(AI_SERVICES.get(selected_service, WEBSITE)) 271 | request = NSURLRequest.requestWithURL_(url) 272 | self.webview.loadRequest_(request) 273 | # Set up script message handler for background color changes 274 | configuration = self.webview.configuration() 275 | user_content_controller = configuration.userContentController() 276 | user_content_controller.addScriptMessageHandler_name_(self, "backgroundColorHandler") 277 | # Inject JavaScript to monitor background color changes 278 | script = """ 279 | function sendBackgroundColor() { 280 | var bgColor = window.getComputedStyle(document.body).backgroundColor; 281 | window.webkit.messageHandlers.backgroundColorHandler.postMessage(bgColor); 282 | } 283 | window.addEventListener('load', sendBackgroundColor); 284 | new MutationObserver(sendBackgroundColor).observe(document.body, { attributes: true, attributeFilter: ['style'] }); 285 | """ 286 | user_script = WKUserScript.alloc().initWithSource_injectionTime_forMainFrameOnly_(script, WKUserScriptInjectionTimeAtDocumentEnd, True) 287 | user_content_controller.addUserScript_(user_script) 288 | # Create status bar item with logo 289 | self.status_item = NSStatusBar.systemStatusBar().statusItemWithLength_(NSSquareStatusItemLength) 290 | script_dir = os.path.dirname(os.path.abspath(__file__)) 291 | logo_white_path = os.path.join(script_dir, LOGO_WHITE_PATH) 292 | self.logo_white = NSImage.alloc().initWithContentsOfFile_(logo_white_path) 293 | self.logo_white.setSize_(NSSize(18, 18)) 294 | logo_black_path = os.path.join(script_dir, LOGO_BLACK_PATH) 295 | self.logo_black = NSImage.alloc().initWithContentsOfFile_(logo_black_path) 296 | self.logo_black.setSize_(NSSize(18, 18)) 297 | # Set the initial logo image based on the current appearance 298 | self.updateStatusItemImage() 299 | # Observe system appearance changes 300 | self.status_item.button().addObserver_forKeyPath_options_context_( 301 | self, "effectiveAppearance", NSKeyValueObservingOptionNew, STATUS_ITEM_CONTEXT 302 | ) 303 | # Create status bar menu 304 | menu = NSMenu.alloc().init() 305 | 306 | # Show/Hide group 307 | show_item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_("Show " + APP_TITLE, "showWindow:", "") 308 | show_item.setTarget_(self) 309 | show_item.setImage_(NSImage.imageWithSystemSymbolName_accessibilityDescription_("rectangle.on.rectangle.angled", None)) 310 | menu.addItem_(show_item) 311 | 312 | hide_item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_("Hide " + APP_TITLE, "hideWindow:", "h") 313 | hide_item.setTarget_(self) 314 | hide_item.setImage_(NSImage.imageWithSystemSymbolName_accessibilityDescription_("eye.slash", None)) 315 | menu.addItem_(hide_item) 316 | 317 | menu.addItem_(NSMenuItem.separatorItem()) 318 | 319 | # Navigation group 320 | home_item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_("Home", "goToWebsite:", "g") 321 | home_item.setTarget_(self) 322 | home_item.setImage_(NSImage.imageWithSystemSymbolName_accessibilityDescription_("house", None)) 323 | menu.addItem_(home_item) 324 | 325 | clear_data_item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_("Clear Web Cache", "clearWebViewData:", "") 326 | clear_data_item.setTarget_(self) 327 | clear_data_item.setImage_(NSImage.imageWithSystemSymbolName_accessibilityDescription_("trash", None)) 328 | menu.addItem_(clear_data_item) 329 | 330 | set_trigger_item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_("Set New Trigger", "setTrigger:", "") 331 | set_trigger_item.setTarget_(self) 332 | set_trigger_item.setImage_(NSImage.imageWithSystemSymbolName_accessibilityDescription_("bolt.fill", None)) 333 | menu.addItem_(set_trigger_item) 334 | 335 | menu.addItem_(NSMenuItem.separatorItem()) 336 | 337 | # Autolaunch group 338 | install_item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_("Install Autolauncher", "install:", "") 339 | install_item.setTarget_(self) 340 | install_item.setImage_(NSImage.imageWithSystemSymbolName_accessibilityDescription_("arrow.up.app", None)) 341 | menu.addItem_(install_item) 342 | 343 | uninstall_item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_("Uninstall Autolauncher", "uninstall:", "") 344 | uninstall_item.setTarget_(self) 345 | uninstall_item.setImage_(NSImage.imageWithSystemSymbolName_accessibilityDescription_("arrow.down.app", None)) 346 | menu.addItem_(uninstall_item) 347 | 348 | menu.addItem_(NSMenuItem.separatorItem()) 349 | 350 | # Quit item 351 | quit_item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_("Quit", "terminate:", "q") 352 | quit_item.setTarget_(NSApp) 353 | quit_item.setImage_(NSImage.imageWithSystemSymbolName_accessibilityDescription_("power", None)) 354 | menu.addItem_(quit_item) 355 | 356 | # Set the menu for the status item 357 | self.status_item.setMenu_(menu) 358 | # Add resize observer 359 | NSNotificationCenter.defaultCenter().addObserver_selector_name_object_( 360 | self, 'windowDidResize:', NSWindowDidResizeNotification, self.window 361 | ) 362 | # Add local mouse event monitor for left mouse down 363 | self.local_mouse_monitor = NSEvent.addLocalMonitorForEventsMatchingMask_handler_( 364 | NSEventMaskLeftMouseDown, # Monitor left mouse-down events 365 | self.handleLocalMouseEvent # Handler method 366 | ) 367 | # Create and store the event tap for key-down events 368 | self.eventTap = CGEventTapCreate( 369 | kCGHIDEventTap, 370 | kCGHeadInsertEventTap, 371 | kCGEventTapOptionDefault, 372 | CGEventMaskBit(kCGEventKeyDown), 373 | global_show_hide_listener(self), 374 | None 375 | ) 376 | # Prompt for Accessibility if API is available 377 | if AXIsProcessTrustedWithOptions and kAXTrustedCheckOptionPrompt: 378 | AXIsProcessTrustedWithOptions({kAXTrustedCheckOptionPrompt: True}) 379 | # Prompt for microphone permission when running via Python 380 | AVCaptureDevice.requestAccessForMediaType_completionHandler_( 381 | AVMediaTypeAudio, 382 | lambda granted: print("Microphone access granted:", granted) 383 | ) 384 | 385 | # Set up signal handler for Ctrl+C 386 | signal.signal(signal.SIGINT, lambda sig, frame: NSApp.terminate_(None)) 387 | # Create a timer to wake up the run loop periodically so signals can be processed 388 | NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_( 389 | 0.5, self, "handleTimer:", None, True 390 | ) 391 | 392 | if self.eventTap: 393 | # Create and add the run loop source 394 | self.eventTapSource = CFMachPortCreateRunLoopSource(None, self.eventTap, 0) 395 | CFRunLoopAddSource(CFRunLoopGetCurrent(), self.eventTapSource, kCFRunLoopCommonModes) 396 | CGEventTapEnable(self.eventTap, True) 397 | else: 398 | print("Failed to create event tap. Check Accessibility permissions.") 399 | # Load the custom launch trigger if the user set it. 400 | load_custom_launcher_trigger() 401 | # Set the delegate of the window to this parent application. 402 | self.window.setDelegate_(self) 403 | # Make sure this window is shown and focused. 404 | self.showWindow_(None) 405 | 406 | # Empty handler for the keep-alive timer 407 | def handleTimer_(self, timer): 408 | pass 409 | 410 | # Logic to show the overlay, make it the key window, and focus on the typing area. 411 | def showWindow_(self, sender): 412 | # Debug log 413 | print("ShowWindow_ called via", sender) 414 | # Unhide and activate the app 415 | NSApp.unhide_(None) 416 | NSApp.activateIgnoringOtherApps_(True) 417 | # Bring window to front 418 | self.window.orderFront_(None) 419 | self.window.makeKeyAndOrderFront_(None) 420 | # Re-enable event tap when showing overlay 421 | if self.eventTap: 422 | CGEventTapEnable(self.eventTap, True) 423 | # Focus the text area 424 | self.webview.evaluateJavaScript_completionHandler_( 425 | "document.querySelector('textarea').focus();", None 426 | ) 427 | 428 | # Hide the overlay and allow focus to return to the next visible application. 429 | def hideWindow_(self, sender): 430 | # Do not disable event tap when hiding overlay, so the global hotkey remains active 431 | NSApp.hide_(None) 432 | 433 | # Go to the default landing website for the overlay (in case accidentally navigated away). 434 | def goToWebsite_(self, sender): 435 | url = NSURL.URLWithString_(WEBSITE) 436 | request = NSURLRequest.requestWithURL_(url) 437 | self.webview.loadRequest_(request) 438 | 439 | # Clear the webview cache data (in case cookies cause errors). 440 | def clearWebViewData_(self, sender): 441 | dataStore = self.webview.configuration().websiteDataStore() 442 | dataTypes = WKWebsiteDataStore.allWebsiteDataTypes() 443 | dataStore.removeDataOfTypes_modifiedSince_completionHandler_( 444 | dataTypes, 445 | NSDate.distantPast(), 446 | lambda: print("Data cleared") 447 | ) 448 | 449 | # Go to the default landing website for the overlay (in case accidentally navigated away). 450 | def install_(self, sender): 451 | if install_startup(): 452 | # Exit the current process since a new one will launch. 453 | print("Installation successful, exiting.", flush=True) 454 | NSApp.terminate_(None) 455 | else: 456 | print("Installation unsuccessful.", flush=True) 457 | 458 | # Go to the default landing website for the overlay (in case accidentally navigated away). 459 | def uninstall_(self, sender): 460 | if uninstall_startup(): 461 | NSApp.hide_(None) 462 | 463 | # Handle the 'Set Trigger' menu item click. 464 | def setTrigger_(self, sender): 465 | set_custom_launcher_trigger(self) 466 | 467 | # For capturing key commands while the key window (in focus). 468 | def keyDown_(self, event): 469 | modifiers = event.modifierFlags() 470 | key_command = modifiers & NSCommandKeyMask 471 | key_alt = modifiers & NSAlternateKeyMask 472 | key_shift = modifiers & NSShiftKeyMask 473 | key_control = modifiers & NSControlKeyMask 474 | key = event.charactersIgnoringModifiers() 475 | # Command (NOT alt) 476 | if (key_command or key_control) and (not key_alt): 477 | # Select all 478 | if key == 'a': 479 | self.window.firstResponder().selectAll_(None) 480 | # Copy 481 | elif key == 'c': 482 | self.window.firstResponder().copy_(None) 483 | # Cut 484 | elif key == 'x': 485 | self.window.firstResponder().cut_(None) 486 | # Paste 487 | elif key == 'v': 488 | self.window.firstResponder().paste_(None) 489 | # Hide 490 | elif key == 'h': 491 | self.hideWindow_(None) 492 | # Quit 493 | elif key == 'q': 494 | NSApp.terminate_(None) 495 | # # Undo (causes crash for some reason) 496 | # elif key == 'z': 497 | # self.window.firstResponder().undo_(None) 498 | 499 | # Handler for capturing a click-and-drag event when not already the key window. 500 | @objc.python_method 501 | def handleLocalMouseEvent(self, event): 502 | if event.window() == self.window: 503 | # Get the click location in window coordinates 504 | click_location = event.locationInWindow() 505 | # Use hitTest_ to determine which view receives the click 506 | hit_view = self.window.contentView().hitTest_(click_location) 507 | # Check if the hit view is the drag area 508 | if hit_view == self.drag_area: 509 | # Bring the window to the front and make it key 510 | self.showWindow_(None) 511 | # Initiate window dragging with the event 512 | self.window.performWindowDragWithEvent_(event) 513 | return None # Consume the event 514 | return event # Pass unhandled events along 515 | 516 | # Handler for when the window resizes (adjusts the drag area). 517 | def windowDidResize_(self, notification): 518 | bounds = self.window.contentView().bounds() 519 | w, h = bounds.size.width, bounds.size.height 520 | self.drag_area.setFrame_(NSMakeRect(0, h - DRAG_AREA_HEIGHT, w, DRAG_AREA_HEIGHT)) 521 | self.webview.setFrame_(NSMakeRect(0, 0, w, h - DRAG_AREA_HEIGHT)) 522 | 523 | # Handler for setting the background color based on the web page background color. 524 | def userContentController_didReceiveScriptMessage_(self, userContentController, message): 525 | if message.name() == "backgroundColorHandler": 526 | bg_color_str = message.body() 527 | # Convert CSS color to NSColor (assuming RGB for simplicity) 528 | if bg_color_str.startswith("rgb") and ("(" in bg_color_str) and (")" in bg_color_str): 529 | rgb_values = [float(val) for val in bg_color_str[bg_color_str.index("(")+1:bg_color_str.index(")")].split(",")] 530 | r, g, b = [val / 255.0 for val in rgb_values[:3]] 531 | color = NSColor.colorWithCalibratedRed_green_blue_alpha_(r, g, b, 1.0) 532 | self.drag_area.setBackgroundColor_(color) 533 | 534 | # Logic for checking what color the logo in the status bar should be, and setting appropriate logo. 535 | def updateStatusItemImage(self): 536 | appearance = self.status_item.button().effectiveAppearance() 537 | if appearance.bestMatchFromAppearancesWithNames_([NSAppearanceNameAqua, NSAppearanceNameDarkAqua]) == NSAppearanceNameDarkAqua: 538 | self.status_item.button().setImage_(self.logo_white) 539 | else: 540 | self.status_item.button().setImage_(self.logo_black) 541 | 542 | # Observer that is triggered whenever the color of the status bar logo might need to be updated. 543 | def observeValueForKeyPath_ofObject_change_context_(self, keyPath, object, change, context): 544 | if context == STATUS_ITEM_CONTEXT and keyPath == "effectiveAppearance": 545 | self.updateStatusItemImage() 546 | 547 | # System triggered appearance changes that might affect logo color. 548 | def appearanceDidChange_(self, notification): 549 | # Update the logo image when the system appearance changes 550 | self.updateStatusItemImage() 551 | # Handler to switch AI service based on dropdown selection 552 | def aiServiceChanged_(self, sender): 553 | selected_service = sender.titleOfSelectedItem() 554 | url = NSURL.URLWithString_(AI_SERVICES[selected_service]) 555 | request = NSURLRequest.requestWithURL_(url) 556 | self.webview.loadRequest_(request) 557 | self.updateDefaultCheckboxState() 558 | 559 | def updateDefaultCheckboxState(self): 560 | """Update the checkbox state based on whether the current selection matches the saved default.""" 561 | current_selection = self.ai_selector.titleOfSelectedItem() 562 | saved_default = self.load_default_service() 563 | if current_selection == saved_default: 564 | self.default_checkbox.setState_(NSControlStateValueOn) 565 | else: 566 | self.default_checkbox.setState_(NSControlStateValueOff) 567 | 568 | def toggleDefault_(self, sender): 569 | """Handle checkbox toggle to set/unset default service.""" 570 | if sender.state() == NSControlStateValueOn: 571 | current_selection = self.ai_selector.titleOfSelectedItem() 572 | self.save_default_service(current_selection) 573 | else: 574 | # If unchecked, revert to hardcoded default (or remove preference) 575 | self.remove_default_service_config() 576 | # Re-check if the current one happens to be the hardcoded default 577 | self.updateDefaultCheckboxState() 578 | 579 | # Quit the application. 580 | def quitApp_(self, sender): 581 | NSApp.terminate_(sender) 582 | 583 | # Increase overlay transparency 584 | def increaseTransparency_(self, sender): 585 | current = self.window.alphaValue() 586 | new_alpha = min(current + 0.1, 1.0) 587 | self.window.setAlphaValue_(new_alpha) 588 | 589 | # Decrease overlay transparency 590 | def decreaseTransparency_(self, sender): 591 | current = self.window.alphaValue() 592 | new_alpha = max(current - 0.1, 0.2) 593 | self.window.setAlphaValue_(new_alpha) 594 | 595 | # Automatically grant microphone permission requests from the webview 596 | def webView_requestMediaCapturePermissionForOrigin_initiatedByFrame_type_decisionListener_(self, webView, origin, frame, mediaType, listener): 597 | # Grant all media capture requests (e.g., microphone) 598 | listener.grant() --------------------------------------------------------------------------------