Skip to content
Open
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
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- run: pip install pyright -r requirements.txt
- name: Install dependencies (wxPython excluded — needs native GUI libs)
run: |
pip install pyright
grep -v wxPython requirements.txt > /tmp/req-ci.txt
pip install -r /tmp/req-ci.txt
- name: Pyright
run: pyright acb_sync/
36 changes: 18 additions & 18 deletions acb_sync/app.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Main application controller for Stream Watcher.

Ties together configuration, file watching, copying, the system tray,
global hotkeys, screen-reader notifications, and the accessible tkinter UI.
global hotkeys, screen-reader notifications, and the accessible wxPython UI.

Cross-platform: Windows, macOS, and Linux.
"""
Expand All @@ -12,6 +12,8 @@
import threading
from pathlib import Path

import wx

from acb_sync import __app_name__, __version__
from acb_sync.config import Config, get_log_path
from acb_sync.copier import CopyRecord, FileCopier
Expand Down Expand Up @@ -46,12 +48,13 @@ def __init__(self) -> None:
self.watcher: FolderWatcher | None = None
self.copier: FileCopier | None = None

# tkinter roothidden, used only to drive the event loop
import tkinter as tk
# wxPython appdrives the native event loop
self._wx_app = wx.App(False)

self._root = tk.Tk()
self._root.title(__app_name__)
self._root.withdraw() # Hide the root window
# Hidden frame to anchor the event loop (never shown)
self._frame = wx.Frame(None, title=__app_name__)
self._frame.Hide()
self._frame.Bind(wx.EVT_CLOSE, lambda e: self.on_quit())

self._settings_win = SettingsWindow(self)
self._status_win = StatusWindow(self)
Expand All @@ -72,15 +75,12 @@ def __init__(self) -> None:
on_quit=self.on_quit,
)

# Ensure clean shutdown on WM_DELETE_WINDOW of root
self._root.protocol("WM_DELETE_WINDOW", self.on_quit)

# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------

def run(self) -> None:
"""Start the application (tray + optional sync, then enter tk mainloop)."""
"""Start the application (tray + optional sync, then enter wx MainLoop)."""
self._setup_logging()

logger.info("%s %s starting.", __app_name__, __version__)
Expand All @@ -102,14 +102,14 @@ def run(self) -> None:

# If not configured, open settings automatically
if not self.config.is_configured():
self._root.after(300, self.on_open_settings)
wx.CallLater(300, self.on_open_settings)
elif not self.config.start_minimized:
self._root.after(300, self.on_open_status)
wx.CallLater(300, self.on_open_status)

notifier.speak(f"{__app_name__} is running.")

# Enter the tkinter main loop
self._root.mainloop()
# Enter the wxPython main loop
self._wx_app.MainLoop()

# ------------------------------------------------------------------
# Sync control
Expand Down Expand Up @@ -195,11 +195,11 @@ def restart_sync(self) -> None:

def on_open_status(self) -> None:
"""Show the status window (thread-safe)."""
self._root.after(0, self._status_win.show)
wx.CallAfter(self._status_win.show)

def on_open_settings(self) -> None:
"""Show the settings window (thread-safe)."""
self._root.after(0, self._settings_win.show)
wx.CallAfter(self._settings_win.show)

def on_toggle_sync(self) -> None:
"""Pause or resume sync."""
Expand Down Expand Up @@ -237,8 +237,8 @@ def on_quit(self) -> None:
self._stop_sync()
self._tray.stop()
notifier.speak(f"{__app_name__} closing.")
self._root.quit()
self._root.destroy()
self._frame.Destroy()
self._wx_app.ExitMainLoop()

def is_sync_enabled(self) -> bool:
"""Return whether sync is currently enabled."""
Expand Down
47 changes: 47 additions & 0 deletions acb_sync/hotkeys.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
"""

import logging
import os
import platform
import threading
from collections.abc import Callable

logger = logging.getLogger(__name__)
Expand All @@ -22,6 +25,47 @@
_HAS_KEYBOARD = False
logger.warning("keyboard library not installed — global hotkeys disabled.")

# ---------------------------------------------------------------------------
# macOS root-privilege check
# ---------------------------------------------------------------------------
_MACOS_ROOT_MSG = (
"Global hotkeys unavailable — the keyboard library requires root on "
"macOS. Run with sudo, or use the menu/status-window controls instead."
)


def _can_listen() -> bool:
"""Return True if the keyboard listener will work on this OS.

On macOS the ``keyboard`` library unconditionally checks
``os.geteuid() == 0`` before starting its listener thread. If the
process is not root the listener raises ``OSError`` in a background
thread and may trigger a SIGTRAP that kills the process. We mirror
that same check here so we can skip registration gracefully.
"""
if platform.system() != "Darwin":
return True
return os.geteuid() == 0


# Safety net: if the keyboard listener thread still dies (e.g. on a platform
# we didn't pre-check), log a warning instead of printing a scary traceback.
_original_excepthook = threading.excepthook


def _hotkey_excepthook(args: threading.ExceptHookArgs) -> None:
if (
args.thread is not None
and args.thread.name == "listen"
and isinstance(args.exc_value, OSError)
):
logger.warning(_MACOS_ROOT_MSG)
return
_original_excepthook(args)


threading.excepthook = _hotkey_excepthook


class GlobalHotkeys:
"""Register and unregister up to five global hotkeys.
Expand Down Expand Up @@ -80,6 +124,9 @@ def register(self) -> None:
return
if self._registered:
return
if not _can_listen():
logger.warning(_MACOS_ROOT_MSG)
return
try:
for name, key in self._keys.items():
if key:
Expand Down
2 changes: 1 addition & 1 deletion acb_sync/notify.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
_HAS_AO2 = False
if IS_WINDOWS:
try:
from accessible_output2.outputs.auto import (
from accessible_output2.outputs.auto import ( # type: ignore[import-not-found]
Auto as _AO2Auto, # type: ignore[import-untyped]
)

Expand Down
26 changes: 19 additions & 7 deletions acb_sync/platform_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ def play_error_sound() -> None:
if IS_WINDOWS:
import winsound # type: ignore[import-untyped]

winsound.MessageBeep(winsound.MB_ICONHAND)
winsound.MessageBeep(winsound.MB_ICONHAND) # type: ignore[attr-defined]
elif IS_MACOS:
# Basso is the standard macOS alert sound
subprocess.Popen(
Expand Down Expand Up @@ -126,10 +126,19 @@ def register_autostart() -> bool:
try:
import winreg # type: ignore[import-untyped]

with winreg.OpenKey(
winreg.HKEY_CURRENT_USER, _AUTOSTART_KEY, 0, winreg.KEY_SET_VALUE
with winreg.OpenKey( # type: ignore[attr-defined]
winreg.HKEY_CURRENT_USER, # type: ignore[attr-defined]
_AUTOSTART_KEY,
0,
winreg.KEY_SET_VALUE, # type: ignore[attr-defined]
) as key:
winreg.SetValueEx(key, _AUTOSTART_NAME, 0, winreg.REG_SZ, cmd)
winreg.SetValueEx( # type: ignore[attr-defined]
key,
_AUTOSTART_NAME,
0,
winreg.REG_SZ, # type: ignore[attr-defined]
cmd,
)
logger.info("Registered Windows autostart.")
return True
except Exception:
Expand Down Expand Up @@ -202,10 +211,13 @@ def unregister_autostart() -> bool:
try:
import winreg

with winreg.OpenKey(
winreg.HKEY_CURRENT_USER, _AUTOSTART_KEY, 0, winreg.KEY_SET_VALUE
with winreg.OpenKey( # type: ignore[attr-defined]
winreg.HKEY_CURRENT_USER, # type: ignore[attr-defined]
_AUTOSTART_KEY,
0,
winreg.KEY_SET_VALUE, # type: ignore[attr-defined]
) as key:
winreg.DeleteValue(key, _AUTOSTART_NAME)
winreg.DeleteValue(key, _AUTOSTART_NAME) # type: ignore[attr-defined]
logger.info("Removed Windows autostart.")
return True
except FileNotFoundError:
Expand Down
26 changes: 20 additions & 6 deletions acb_sync/tray.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@

import contextlib
import logging
import platform
import threading
from typing import Protocol, Any
from typing import Any, Protocol

import pystray
from PIL import Image, ImageDraw
Expand Down Expand Up @@ -71,7 +72,7 @@ def _create_icon_image(color: str = "#0078D4", size: int = 64) -> PILImage:
class SysTray:
"""Manages the system-tray icon and its context menu.

The tray runs on its own thread so it does not block the tkinter main loop.
The tray runs on its own thread so it does not block the wx main loop.
"""

def __init__(self, callbacks: TrayCallbacks):
Expand Down Expand Up @@ -99,7 +100,15 @@ def _build_menu(self) -> pystray.Menu:
)

def start(self) -> None:
"""Start the tray icon on a daemon thread."""
"""Start the tray icon.

On macOS, ``pystray``'s ``Icon.run()`` calls ``NSApplication.run()``
which must own the main thread — but wxPython also needs the main
thread. We use ``run_detached()`` instead, which lets the existing
wx main-loop drive event processing.

On other platforms the icon runs on a daemon thread as before.
"""
icon_img = _create_icon_image()
self._icon = pystray.Icon(
name="StreamWatcher",
Expand All @@ -108,12 +117,17 @@ def start(self) -> None:
menu=self._build_menu(),
)

# Capture local reference so type-checker knows it's not None
icon = self._icon
if icon is None:
return
self._thread = threading.Thread(target=icon.run, daemon=True, name="SysTray")
self._thread.start()

if platform.system() == "Darwin":
icon.run_detached()
else:
self._thread = threading.Thread(
target=icon.run, daemon=True, name="SysTray"
)
self._thread.start()
logger.info("System tray icon started.")

def stop(self) -> None:
Expand Down
Loading