moses-palmer / pynput

Sends virtual input commands
GNU Lesser General Public License v3.0
1.78k stars 248 forks source link

MacOS crash when starting pynput keyboard listener after qt6 #511

Open perroboc opened 1 year ago

perroboc commented 1 year ago

Description If I create and start a listener AFTER launching a qt6 app (pyside6), the application crashes.

Platform and pynput version macOS Monterey (12.6), M1 Pro. Pynput 1.7.6, Pyside6 6.4.0, Python 3.10. I'm also using a Latin American keyboard layout.

To Reproduce https://github.com/alvaromunoz/pynput-macos-issues/blob/ba17e4b83f1eb239454d73ba96adf7f5fe71673b/nested_crash.py

import sys

from PySide6 import (
    QtWidgets,
)

from pynput import keyboard

class MyListener():

    def __init__(self, label: QtWidgets.QLabel):

        def on_press(key):
            label.setText("You pressed {0}".format(key))

        def on_release(key):
            label.setText("You released {0}".format(key))

        self.listener = keyboard.Listener(
            on_press=on_press,
            on_release=on_release)

    def start(self):
        self.listener.start()
        self.listener.wait()
        print("listener has started? {0}".format(self.listener.running))

class MainWindow(QtWidgets.QMainWindow):
    def __init__(self):
        super().__init__()

        self.setWindowTitle("Listener Demo 2")

        central_widget = QtWidgets.QWidget()

        layout = QtWidgets.QVBoxLayout()
        central_widget.setLayout(layout)

        label_description = QtWidgets.QLabel("Press the button to crash")
        layout.addWidget(label_description)

        label = QtWidgets.QLabel("Press any key")
        label.setEnabled(False)
        layout.addWidget(label)

        button = QtWidgets.QPushButton("Start listener!")
        button.pressed.connect(self.start_listener)
        layout.addWidget(button)

        self.listener = MyListener(label)

        self.setCentralWidget(central_widget)

    def start_listener(self):
        print("beep")
        self.listener.start()
        print("boop")

app = QtWidgets.QApplication(sys.argv)

window = MainWindow()
window.show()

app.exec()

OUTPUT:

beep
zsh: trace trap
perroboc commented 1 year ago

FYI: this happens with tkinter and pyqt6, too:

tkinter:

from tkinter import *

from pynput import keyboard

class MyListener():

    def __init__(self):

        def on_press(key):
            print("You pressed {0}".format(key))

        def on_release(key):
            print("You released {0}".format(key))

        self.listener = keyboard.Listener(
            on_press=on_press,
            on_release=on_release)

    def start(self):
        self.listener.start()
        self.listener.wait()
        print("listener has started? {0}".format(self.listener.running))

listener = MyListener()

root = Tk()  # create parent window

def start_pynput():
    print("beep")
    listener.start()
    print("boop")

# use Button and Label widgets to create a simple TV remote
button = Button(root, text="Start Pynput", command=start_pynput)
button.pack()

root.mainloop()

pyqt6

import sys

from PyQt6 import (
    QtWidgets,
)

from pynput import keyboard

class MyListener():

    def __init__(self, label: QtWidgets.QLabel):

        def on_press(key):
            label.setText("You pressed {0}".format(key))

        def on_release(key):
            label.setText("You released {0}".format(key))

        self.listener = keyboard.Listener(
            on_press=on_press,
            on_release=on_release)

    def start(self):
        self.listener.start()
        self.listener.wait()
        print("listener has started? {0}".format(self.listener.running))

class MainWindow(QtWidgets.QMainWindow):
    def __init__(self):
        super().__init__()

        self.setWindowTitle("Listener Demo 2")

        central_widget = QtWidgets.QWidget()

        layout = QtWidgets.QVBoxLayout()
        central_widget.setLayout(layout)

        label_description = QtWidgets.QLabel("Press the button to crash")
        layout.addWidget(label_description)

        label = QtWidgets.QLabel("Press any key")
        label.setEnabled(False)
        layout.addWidget(label)

        button = QtWidgets.QPushButton("Start listener!")
        button.pressed.connect(self.start_listener)
        layout.addWidget(button)

        self.listener = MyListener(label)

        self.setCentralWidget(central_widget)

    def start_listener(self):
        print("beep")
        self.listener.start()
        print("boop")

app = QtWidgets.QApplication(sys.argv)

window = MainWindow()
window.show()

app.exec()
ba361006 commented 1 year ago

Same here, pynput works fine pyperclip, PySide6 at the first place, but it shows error message as follow after pressing the button.

Error message: [1] 31161 trace trap /<path_to_current_folder/.venv/bin/python

working environment:

here's the code

# -*- coding: utf-8 -*-
import random
import string
import time

import pyperclip
from pynput import keyboard
from PySide6 import QtCore
from PySide6 import QtWidgets

class Main(QtWidgets.QMainWindow):
    signal = QtCore.Signal()

    def __init__(self):
        super().__init__()

    def build(self):
        self.signal.connect(self.get_text_from_clip)
        self.button = QtWidgets.QPushButton()
        self.button.clicked.connect(self.button_pressed)
        self.keyboard_detect_start()
        self.setCentralWidget(self.button)
        self.show()

    def keyboard_detect_start(self, key="<ctrl>+q"):
        def for_canonical(hotkey_event):
            return lambda key: hotkey_event(self.listener.canonical(key))

        hotkey = keyboard.HotKey(keyboard.HotKey.parse(key), self.signal.emit)
        self.listener = keyboard.Listener(
            on_press=for_canonical(hotkey.press),
            on_release=for_canonical(hotkey.release),
        )
        self.listener.start()

    def get_text_from_clip(self):
        controller = keyboard.Controller()
        controller.press(keyboard.Key.cmd)
        controller.press("c")
        controller.release("c")
        controller.release(keyboard.Key.cmd)
        time.sleep(0.05)
        print(pyperclip.paste().strip())

    def button_pressed(self):
        self.listener.stop()
        self.listener.join()
        time.sleep(0.5)
        new_key = random.choices(string.ascii_lowercase)[0]
        print("new_key: ", new_key)
        self.keyboard_detect_start(key=f"<ctrl>+{new_key}")

if __name__ == "__main__":
    app = QtWidgets.QApplication([])
    main = Main()
    main.build()
    app.exec()
perroboc commented 1 year ago

@ba361006 could you please check if #512 fixes your issue?

ba361006 commented 1 year ago

@alvaromunoz

Yes, #512 does fix this for me. It would be appreciated that if #512 can be merged :)

[Edited] 2022/11/22 With applying #512 , the following fail case still raises [1] 93758 trace trap <path_to_project>/.venv/bin/python error, but somehow it works by invoking print_text_from_clipboard via signal

So far I know is that the fail case stop at controller = keyboard.Controller() and raise the error mentioned above. Firstly I thought maybe Mac will limit only one thread to have control of keyboard event, so I instantiate worker and connect the signal under Main which should be at the same scope with the app, and eventually it works.

But if I simply change the way of invoking print_text_from_clipboard it fails, which is the only difference between fail and work case.

class Worker(QtCore.QObject): def init(self): super().init()

def run_method(self):
    activate_key = "<ctrl>+q"
    def for_canonical(hotkey_event):
        return lambda key: hotkey_event(self.listener.canonical(key))
    hotkey = keyboard.HotKey(keyboard.HotKey.parse(activate_key), self.print_text_from_clipboard)
    self.listener = keyboard.Listener(
        on_press=for_canonical(hotkey.press),
        on_release=for_canonical(hotkey.release),
    )
    self.listener.start()

def print_text_from_clipboard(self):
    # delay is needed before getting str from clip
    if platform.system() == "Windows":
        modifier = keyboard.Key.ctrl
    elif platform.system() == "Darwin":
        modifier = keyboard.Key.cmd
    controller = keyboard.Controller()
    controller.press(modifier)
    controller.press("c")
    controller.release("c")
    controller.release(modifier)
    time.sleep(0.05)
    print("text: ", pyperclip.paste().strip())

class Main(QtWidgets.QMainWindow): def init(self): super().init()

def build(self):
    self.worker = Worker()

    self.button = QtWidgets.QPushButton()
    self.button.clicked.connect(self.button_pressed)
    self.setCentralWidget(self.button)
    self.show()

def button_pressed(self):
    self.worker.run_method()

if name == "main": app = QtWidgets.QApplication([]) main = Main() main.build() app.exec()


- Work case
 ```python
# -*- coding: utf-8 -*-
import time
import platform
import pyperclip
from pynput import keyboard
from PySide6 import QtCore
from PySide6 import QtWidgets

class Worker(QtCore.QObject):
    signal = QtCore.Signal()
    def __init__(self):
        super().__init__()

    def run_method(self):
        activate_key = "<ctrl>+q"
        def for_canonical(hotkey_event):
            return lambda key: hotkey_event(self.listener.canonical(key))

        hotkey = keyboard.HotKey(keyboard.HotKey.parse(activate_key), self.signal.emit)
        self.listener = keyboard.Listener(
            on_press=for_canonical(hotkey.press),
            on_release=for_canonical(hotkey.release),
        )
        self.listener.start()

    def print_text_from_clipboard(self):
        # delay is needed before getting str from clip
        if platform.system() == "Windows":
            modifier = keyboard.Key.ctrl
        elif platform.system() == "Darwin":
            modifier = keyboard.Key.cmd
        controller = keyboard.Controller()
        controller.press(modifier)
        controller.press("c")
        controller.release("c")
        controller.release(modifier)
        time.sleep(0.05)
        print("text: ", pyperclip.paste().strip())

class Main(QtWidgets.QMainWindow):
    def __init__(self):
        super().__init__()

    def build(self):
        self.worker = Worker()
        self.worker.signal.connect(self.worker.print_text_from_clipboard)

        self.button = QtWidgets.QPushButton()
        self.button.clicked.connect(self.button_pressed)
        self.setCentralWidget(self.button)
        self.show()

    def button_pressed(self):
        self.worker.run_method()

if __name__ == "__main__":
    app = QtWidgets.QApplication([])
    main = Main()
    main.build()
    app.exec()
ba361006 commented 1 year ago

Just found something interesting.

Without applying changes in #512, the following code works.
It seems that you can't invoking keyboard.Listener via Signal or clicked.connect.

It reminds me that PyQt5 have some tricky stuff with QThread related to scope problem, which means where you instantiate the QThread or invoking moveToThread to your customised QObject class matters, but I can't recall the detail.

Does this code work for you? @alvaromunoz

# -*- coding: utf-8 -*-
from pynput import keyboard
from PySide6 import QtWidgets

class Main(QtWidgets.QMainWindow):
    def __init__(self):
        super().__init__()

    def build(self):
        def for_canonical(hotkey_event):
            return lambda key: hotkey_event(self.listener.canonical(key))
        hotkey = keyboard.HotKey(keyboard.HotKey.parse("<ctrl>+q"), lambda: print("keyboard detected"))
        self.listener = keyboard.Listener(
            on_press=for_canonical(hotkey.press),
            on_release=for_canonical(hotkey.release),
        )
        self.listener.start()
        self.show()

if __name__ == "__main__":
    app = QtWidgets.QApplication([])
    main = Main()
    main.build()
    app.exec()
9783e6 commented 1 year ago

Same issue, MacBook Air M2 on MacOs Sonoma 14.0 Beta