Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions .github/workflows/compile-python.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
name: Compile Python ZoomMate

on:
push:
branches: [ main, master ]
paths:
- 'app.py'
- 'pyproject.toml'
- 'requirements.txt'
- 'scripts/build_exe.ps1'
- 'pyzoommate/**'
- 'Includes/**'
- 'images/**'
pull_request:
branches: [ main, master ]
paths:
- 'app.py'
- 'pyproject.toml'
- 'requirements.txt'
- 'scripts/build_exe.ps1'
- 'pyzoommate/**'
- 'Includes/**'
- 'images/**'
workflow_dispatch:

jobs:
compile:
runs-on: windows-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'

- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install -r requirements.txt

- name: Compile Python executable
shell: pwsh
run: |
python -m PyInstaller --noconfirm --onefile --name "ZoomMate-Python" --icon "zoommate.ico" --distpath "build" --add-data "images;images" --add-data "Includes;Includes" app.py

- name: Verify compilation
shell: pwsh
run: |
if (Test-Path "build/ZoomMate-Python.exe") {
$fileSize = (Get-Item "build/ZoomMate-Python.exe").Length
Write-Host "Compilation successful! File size: $fileSize bytes"
} else {
Write-Host "Compilation failed - executable not found"
exit 1
}

- name: Upload executable artifact
uses: actions/upload-artifact@v4
with:
name: ZoomMate-Python-exe
path: build/ZoomMate-Python.exe

- name: Commit compiled executable
if: github.event_name == 'push'
shell: pwsh
run: |
git config --local user.email "[email protected]"
git config --local user.name "GitHub Action"
git add build/ZoomMate-Python.exe
git diff --quiet && git diff --staged --quiet || git commit -m "Update Python compiled executable [skip ci]"
git push
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ name = "pyzoommate"
version = "0.1.0"
description = "Python migration of ZoomMate meeting automation"
requires-python = ">=3.10"
dependencies = []
dependencies = [
"pyautogui>=0.9.54",
"pywinauto>=0.6.8",
]

[project.scripts]
pyzoommate = "pyzoommate.app:main"
112 changes: 107 additions & 5 deletions pyzoommate/automation/meeting_automation.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
import webbrowser
from datetime import datetime

from ..diagnostics.path_wizard import ResolveSecurityToggle
from ..globals import STATE
from ..user_settings import get_user_setting
from .zoom_operations import FocusZoomWindow, _GetZoomWindow, _OpenParticipantsPanel
from .zoom_operations import _backend as _AUTOMATION_BACKEND

logger = logging.getLogger(__name__)

Expand All @@ -18,34 +20,134 @@


def _SnapZoomWindowToSide() -> bool:
# Window snap is left as a no-op for now unless backend-specific support is added.
return True
side = get_user_setting("SnapZoomSide", "Disabled").strip().lower()
if side not in {"left", "right"}:
return True

if not FocusZoomWindow():
return False

try:
import pyautogui

pyautogui.hotkey("win", side)
return True
except Exception:
return False


def SetSecuritySetting(setting_name: str, desired: bool) -> bool:
logger.info("action=set_security setting=%s desired=%s", setting_name, desired)
setting = ResolveSecurityToggle(setting_name)
if setting is None:
return False

is_enabled: bool | None = None
try:
toggle_iface = getattr(setting, "iface_toggle", None)
if toggle_iface is not None and hasattr(toggle_iface, "CurrentToggleState"):
is_enabled = int(toggle_iface.CurrentToggleState) == 1
except Exception:
is_enabled = None

if is_enabled is None:
label = getattr(getattr(setting, "element_info", None), "name", "") or ""
unchecked_value = get_user_setting("UncheckedValue").strip().lower()
if unchecked_value:
is_enabled = unchecked_value not in label.lower()

if is_enabled is not None and is_enabled == desired:
return True

if not _AUTOMATION_BACKEND.click(setting, force=True):
return False
time.sleep(0.25)
return True


def MuteAll() -> bool:
logger.info("action=mute_all")
return True
if not _OpenParticipantsPanel():
return False

zoom_window = _GetZoomWindow()
if zoom_window is None:
return False

mute_all = _AUTOMATION_BACKEND.find_by_partial_name(get_user_setting("MuteAllValue"), scope=zoom_window)
if mute_all is None or not _AUTOMATION_BACKEND.click(mute_all):
return False

dialog = _AUTOMATION_BACKEND.find_window(class_name="zChangeNameWndClass")
if dialog is None:
return True
yes_button = _AUTOMATION_BACKEND.find_by_partial_name(get_user_setting("YesValue"), scope=dialog)
return yes_button is not None and _AUTOMATION_BACKEND.click(yes_button)


def ToggleFeed(feed_type: str, desired_state: bool) -> bool:
logger.info("action=toggle_feed feed=%s desired=%s", feed_type, desired_state)
return True
zoom_window = _GetZoomWindow()
if zoom_window is None:
return False

_AUTOMATION_BACKEND.move_mouse_to_element_start(zoom_window, click=False)

feed = feed_type.strip().lower()
if feed == "video":
enabled_button = _AUTOMATION_BACKEND.find_by_partial_name(get_user_setting("StopVideoValue"), scope=zoom_window)
disabled_button = _AUTOMATION_BACKEND.find_by_partial_name(get_user_setting("StartVideoValue"), scope=zoom_window)
elif feed == "audio":
enabled_button = _AUTOMATION_BACKEND.find_by_partial_name(get_user_setting("CurrentlyUnmutedValue"), scope=zoom_window)
disabled_button = _AUTOMATION_BACKEND.find_by_partial_name(get_user_setting("UnmuteAudioValue"), scope=zoom_window)
else:
return False

currently_enabled = enabled_button is not None
if currently_enabled == desired_state:
return True

target = enabled_button if currently_enabled else disabled_button
if target is None:
return False
return _AUTOMATION_BACKEND.click(target)


def PulseSpotlightHostVideo(duration_ms: int = 5000) -> bool:
logger.info("action=pulse_spotlight duration_ms=%s", duration_ms)
if not _OpenParticipantsPanel():
time.sleep(duration_ms / 1000)
return False

zoom_window = _GetZoomWindow()
if zoom_window is None:
time.sleep(duration_ms / 1000)
return False

spotlight = _AUTOMATION_BACKEND.find_by_partial_name("spotlight", scope=zoom_window)
if spotlight is None or not _AUTOMATION_BACKEND.click(spotlight, force=True):
time.sleep(duration_ms / 1000)
return False

time.sleep(duration_ms / 1000)
remove_spotlight = _AUTOMATION_BACKEND.find_by_partial_name("remove spotlight", scope=zoom_window)
if remove_spotlight is not None:
_AUTOMATION_BACKEND.click(remove_spotlight, force=True)
return True


def EnsureGalleryView() -> bool:
logger.info("action=ensure_gallery_view")
return True
if not FocusZoomWindow():
return False
try:
import pyautogui

pyautogui.hotkey("alt", "f2")
time.sleep(0.3)
return True
except Exception:
return False


def _LaunchZoom() -> bool:
Expand Down
71 changes: 70 additions & 1 deletion pyzoommate/diagnostics/path_wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@
from datetime import datetime
from pathlib import Path

from ..automation.zoom_operations import (
FocusZoomWindow,
GetMoreMenu,
_FindHostToolsContainer,
_GetZoomWindow,
_OpenHostTools,
)
from ..automation.zoom_operations import _backend as _AUTOMATION_BACKEND
from ..user_settings import get_user_setting, set_user_setting

_LAST_PATH_ERROR = ""
Expand All @@ -22,31 +30,92 @@ def _GetPathError() -> str:


def EnsureZoomMainWindow() -> bool:
_SetPathError("")
zoom_window = _GetZoomWindow()
if zoom_window is None:
_SetPathError("Zoom main window not found.")
return False
if not FocusZoomWindow():
_SetPathError("Unable to focus Zoom main window.")
return False
return True


def EnsureMoreMenuVisible() -> bool:
if not EnsureZoomMainWindow():
return False
if GetMoreMenu() is None:
_SetPathError("Unable to open Zoom More menu.")
return False
return True


def EnsureHostToolsVisible() -> bool:
if not EnsureZoomMainWindow():
return False
if not _OpenHostTools():
_SetPathError("Unable to open Host Tools panel/menu.")
return False
return True


def EnsureHostToolsParticipantsScope() -> bool:
if not EnsureHostToolsVisible():
return False

host_tools = _FindHostToolsContainer()
if host_tools is None:
_SetPathError("Host Tools container was not found after opening Host Tools.")
return False

participants = _AUTOMATION_BACKEND.find_by_partial_name(get_user_setting("ParticipantValue"), scope=host_tools)
if participants is not None:
_AUTOMATION_BACKEND.click(participants, force=True)
return True


def ResolveSecurityToggle(setting: str):
if not setting.strip():
_SetPathError("Security toggle name is empty.")
return None

if not EnsureHostToolsParticipantsScope():
return None

host_tools = _FindHostToolsContainer()
if host_tools is None:
return None

control_types = ("CheckBox", "Button", "MenuItem", "Text")
element = _AUTOMATION_BACKEND.find_by_partial_name(setting, control_types=control_types, scope=host_tools)
if element is None:
zoom_window = _GetZoomWindow()
if zoom_window is not None:
element = _AUTOMATION_BACKEND.find_by_partial_name(setting, control_types=control_types, scope=zoom_window)
if element is None:
_SetPathError(f"Security toggle not found: {setting}")
return None
return element


def EnsureSecurityToggleVisible(setting: str) -> bool:
return bool(setting)
return ResolveSecurityToggle(setting) is not None


def RunUIDiagnostics() -> Path:
zoom_window_present = _GetZoomWindow() is not None
host_tools_visible = EnsureHostToolsVisible()
more_menu_visible = EnsureMoreMenuVisible()

rows = [
f"Timestamp: {datetime.now().isoformat()}",
f"ZoomWindowVisible={zoom_window_present}",
f"MoreMenuVisible={more_menu_visible}",
f"HostToolsVisible={host_tools_visible}",
f"HostToolsValue={get_user_setting('HostToolsValue')}",
f"MoreMeetingControlsValue={get_user_setting('MoreMeetingControlsValue')}",
f"ParticipantValue={get_user_setting('ParticipantValue')}",
f"LastPathError={_GetPathError()}",
]
DIAGNOSTICS_FILE.write_text("\n".join(rows) + "\n", encoding="utf-8")
return DIAGNOSTICS_FILE
Expand Down
3 changes: 2 additions & 1 deletion pyzoommate/diagnostics/state_profiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from pathlib import Path

from ..diagnostics.path_wizard import EnsureHostToolsVisible, EnsureZoomMainWindow
from ..automation.zoom_operations import _FindParticipantsPanelInternal

STATE_PROFILE_INI = Path("zoom_state_profiles.ini")
STATE_PROFILE_TXT = Path("zoom_state_profiles.txt")
Expand All @@ -16,7 +17,7 @@ def GetCurrentZoomStateFlags() -> dict[str, bool]:
return {
"zoom_window_visible": EnsureZoomMainWindow(),
"host_tools_visible": EnsureHostToolsVisible(),
"participants_panel_visible": True,
"participants_panel_visible": _FindParticipantsPanelInternal() is not None,
}


Expand Down
4 changes: 3 additions & 1 deletion pyzoommate/i18n/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import re
import sys
from dataclasses import dataclass, field
from pathlib import Path

Expand Down Expand Up @@ -34,7 +35,8 @@ def _parse_translation_file(path: Path) -> dict[str, str]:


def _InitializeTranslations(language: str = "en") -> None:
includes_dir = Path(__file__).resolve().parents[2] / "Includes"
bundle_root = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parents[2]))
includes_dir = bundle_root / "Includes"
translations: dict[str, dict[str, str]] = {}
for lang, filename in _LANG_FILES.items():
file_path = includes_dir / filename
Expand Down
2 changes: 1 addition & 1 deletion scripts/build_exe.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ param(
)

& $Python -m pip install -r requirements.txt
& $Python -m PyInstaller --noconfirm --onefile --name ZoomMate --icon zoommate.ico --add-data "images;images" app.py
& $Python -m PyInstaller --noconfirm --onefile --name ZoomMate --icon zoommate.ico --add-data "images;images" --add-data "Includes;Includes" app.py