diff --git a/porcupine/__main__.py b/porcupine/__main__.py index 1c8ea9a4b..c7c33381f 100644 --- a/porcupine/__main__.py +++ b/porcupine/__main__.py @@ -1,20 +1,10 @@ +from __future__ import annotations + import argparse import logging -import sys -from pathlib import Path from porcupine import __version__ as porcupine_version -from porcupine import ( - _logs, - _state, - dirs, - get_main_window, - get_tab_manager, - menubar, - pluginloader, - settings, - tabs, -) +from porcupine import _logs, _state, dirs, get_main_window, menubar, pluginloader, settings log = logging.getLogger(__name__) @@ -133,6 +123,7 @@ def main() -> None: ) args = parser.parse_args() + _state.init(args) # Prevent showing up a not-ready-yet root window to user @@ -142,25 +133,14 @@ def main() -> None: menubar._init() pluginloader.run_setup_functions(args.shuffle_plugins) - tabmanager = get_tab_manager() - for path_string in args.files: - if path_string == "-": - # don't close stdin so it's possible to do this: - # - # $ porcu - - - # bla bla bla - # ^D - # bla bla - # ^D - tabmanager.add_tab(tabs.FileTab(tabmanager, content=sys.stdin.read())) - else: - tabmanager.open_file(Path(path_string)) + _state.open_files(args.files) get_main_window().deiconify() try: get_main_window().mainloop() finally: settings.save() + log.info("exiting Porcupine successfully") diff --git a/porcupine/_ipc.py b/porcupine/_ipc.py new file mode 100644 index 000000000..50bbb05c5 --- /dev/null +++ b/porcupine/_ipc.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import queue +import threading +from multiprocessing import connection +from pathlib import Path +from typing import Any + +from porcupine import dirs + +_ADDRESS_FILE = Path(dirs.user_cache_dir) / "ipc_address.txt" + + +# the addresses contain random junk so they are very unlikely to +# conflict with each other +# example addresses: r'\\.\pipe\pyc-1412-1-7hyryfd_', +# '/tmp/pymp-_lk54sed/listener-4o8n1xrc', +def send(objects: list[Any]) -> None: + """Send objects from an iterable to a process running session(). + + Raise ConnectionRefusedError if session() is not running. + """ + # reading the address file, connecting to a windows named pipe and + # connecting to an AF_UNIX socket all raise FileNotFoundError :D + try: + with _ADDRESS_FILE.open("r") as file: + address = file.read().strip() + client = connection.Client(address) + except FileNotFoundError: + raise ConnectionRefusedError("session() is not running") from None + + with client: + for message in objects: + client.send(message) + + +def _listener2queue(listener: connection.Listener, object_queue: queue.Queue[Any]) -> None: + """Accept connections. Receive and queue objects.""" + while True: + try: + client = listener.accept() + except OSError: + # it's closed + break + + with client: + while True: + try: + object_queue.put(client.recv()) + except EOFError: + break + + +def start_session() -> tuple[connection.Listener, queue.Queue[Any]]: + """Start the listener session. Return the listener object, and the message queue. + The listener has to be closed manually. + """ + message_queue: queue.Queue[Any] = queue.Queue() + listener = connection.Listener() + + with _ADDRESS_FILE.open("w") as file: + print(listener.address, file=file) + + thread = threading.Thread(target=_listener2queue, args=[listener, message_queue], daemon=True) + thread.start() + + return listener, message_queue diff --git a/porcupine/_state.py b/porcupine/_state.py index 181de330b..60e50055d 100644 --- a/porcupine/_state.py +++ b/porcupine/_state.py @@ -4,12 +4,16 @@ import dataclasses import logging import os +import queue import sys import tkinter import types -from typing import Any, Callable, Type +from multiprocessing import connection +from pathlib import Path +from typing import Any, Callable, Iterable, Type -from porcupine import images, tabs, utils +from porcupine import _ipc, images, tabs, utils +from porcupine.tabs import FileTab # Windows resolution if sys.platform == "win32": @@ -32,11 +36,14 @@ class _State: tab_manager: tabs.TabManager quit_callbacks: list[Callable[[], bool]] parsed_args: Any # not None + ipc_session: connection.Listener # global state makes some things a lot easier (I'm sorry) _global_state: _State | None = None +Quit = object() + def _log_tkinter_error( exc: Type[BaseException], val: BaseException, tb: types.TracebackType | None @@ -44,6 +51,37 @@ def _log_tkinter_error( log.error("Error in tkinter callback", exc_info=(exc, val, tb)) +def open_files(files: Iterable[str]) -> None: + tabmanager = get_tab_manager() + for path_string in files: + if path_string == "-": + # don't close stdin so it's possible to do this: + # + # $ porcu - - + # bla bla bla + # ^D + # bla bla + # ^D + tabmanager.add_tab(FileTab(tabmanager, content=sys.stdin.read())) + else: + tabmanager.open_file(Path(path_string)) + + +def listen_for_files(message_queue: queue.Queue[Any]) -> None: + try: + message = message_queue.get_nowait() + except queue.Empty: + message = None + else: + try: + open_files([message]) + except Exception as e: + log.error(e) + + if message is not Quit: + _get_state().root.after(500, listen_for_files, message_queue) + + # undocumented on purpose, don't use in plugins def init(args: Any) -> None: assert args is not None @@ -53,6 +91,14 @@ def init(args: Any) -> None: log.debug("init() starts") + try: + _ipc.send(args.files) + except ConnectionRefusedError: + ipc_session, message_queue = _ipc.start_session() + else: + log.info("another instance of Porcupine is already running, files were sent to it") + sys.exit() + root = tkinter.Tk(className="Porcupine") # class name shows up in my alt+tab list log.debug("root window created") log.debug("Tcl/Tk version: " + root.tk.eval("info patchlevel")) @@ -81,7 +127,11 @@ def init(args: Any) -> None: tab_manager=tab_manager, quit_callbacks=[], parsed_args=args, + ipc_session=ipc_session, ) + + listen_for_files(message_queue) + log.debug("init() done") @@ -140,6 +190,9 @@ def quit() -> None: if not callback(): return + _ipc.send([Quit]) + _get_state().ipc_session.close() + for tab in get_tab_manager().tabs(): get_tab_manager().close_tab(tab) get_main_window().destroy()