411 lines
12 KiB
Python
411 lines
12 KiB
Python
import json
|
|
import os
|
|
import time
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from tempfile import gettempdir
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
from talon import Context, Module, actions, speech_system
|
|
|
|
# How old a request file needs to be before we declare it stale and are willing
|
|
# to remove it
|
|
STALE_TIMEOUT_MS = 60_000
|
|
|
|
# The amount of time to wait for application to perform a command, in seconds
|
|
RPC_COMMAND_TIMEOUT_SECONDS = 3.0
|
|
|
|
# When doing exponential back off waiting for application to perform a command, how
|
|
# long to sleep the first time
|
|
MINIMUM_SLEEP_TIME_SECONDS = 0.0005
|
|
|
|
# Indicates whether a pre-phrase signal was emitted during the course of the
|
|
# current phrase
|
|
did_emit_pre_phrase_signal = False
|
|
|
|
mod = Module()
|
|
ctx = Context()
|
|
mac_ctx = Context()
|
|
|
|
ctx.matches = r"""
|
|
tag: user.command_client
|
|
"""
|
|
mac_ctx.matches = r"""
|
|
os: mac
|
|
tag: user.command_client
|
|
"""
|
|
|
|
|
|
class NotSet:
|
|
def __repr__(self):
|
|
return "<argument not set>"
|
|
|
|
|
|
class NoFileServerException(Exception):
|
|
pass
|
|
|
|
|
|
def write_json_exclusive(path: Path, body: Any):
|
|
"""Writes jsonified object to file, failing if the file already exists
|
|
|
|
Args:
|
|
path (Path): The path of the file to write
|
|
body (Any): The object to convert to json and write
|
|
"""
|
|
with path.open("x") as out_file:
|
|
out_file.write(json.dumps(body))
|
|
|
|
|
|
@dataclass
|
|
class Request:
|
|
command_id: str
|
|
args: list[Any]
|
|
wait_for_finish: bool
|
|
return_command_output: bool
|
|
uuid: str
|
|
|
|
def to_dict(self):
|
|
return {
|
|
"commandId": self.command_id,
|
|
"args": self.args,
|
|
"waitForFinish": self.wait_for_finish,
|
|
"returnCommandOutput": self.return_command_output,
|
|
"uuid": self.uuid,
|
|
}
|
|
|
|
|
|
def write_request(request: Request, path: Path):
|
|
"""Converts the given request to json and writes it to the file, failing if
|
|
the file already exists unless it is stale in which case it replaces it
|
|
|
|
Args:
|
|
request (Request): The request to serialize
|
|
path (Path): The path to write to
|
|
|
|
Raises:
|
|
Exception: If another process has an active request file
|
|
"""
|
|
try:
|
|
write_json_exclusive(path, request.to_dict())
|
|
request_file_exists = False
|
|
except FileExistsError:
|
|
request_file_exists = True
|
|
|
|
if request_file_exists:
|
|
handle_existing_request_file(path)
|
|
write_json_exclusive(path, request.to_dict())
|
|
|
|
|
|
def handle_existing_request_file(path):
|
|
stats = path.stat()
|
|
|
|
modified_time_ms = stats.st_mtime_ns / 1e6
|
|
current_time_ms = time.time() * 1e3
|
|
time_difference_ms = abs(modified_time_ms - current_time_ms)
|
|
|
|
if time_difference_ms < STALE_TIMEOUT_MS:
|
|
raise Exception(
|
|
"Found recent request file; another Talon process is probably running"
|
|
)
|
|
|
|
print("Removing stale request file")
|
|
robust_unlink(path)
|
|
|
|
|
|
def run_command(
|
|
command_id: str,
|
|
*args,
|
|
wait_for_finish: bool = False,
|
|
return_command_output: bool = False,
|
|
):
|
|
"""Runs a command, using command server if available
|
|
|
|
Args:
|
|
command_id (str): The ID of the command to run.
|
|
args: The arguments to the command.
|
|
wait_for_finish (bool, optional): Whether to wait for the command to finish before returning. Defaults to False.
|
|
return_command_output (bool, optional): Whether to return the output of the command. Defaults to False.
|
|
|
|
Raises:
|
|
Exception: If there is an issue with the file-based communication, or
|
|
application raises an exception
|
|
|
|
Returns:
|
|
Object: The response from the command, if requested.
|
|
"""
|
|
# NB: This is a hack to work around the fact that talon doesn't support
|
|
# variable argument lists
|
|
args = [x for x in args if x is not NotSet]
|
|
|
|
communication_dir_path = get_communication_dir_path()
|
|
|
|
if not communication_dir_path.exists():
|
|
if args or return_command_output:
|
|
raise Exception("Must use command-server extension for advanced commands")
|
|
raise NoFileServerException("Communication directory not found")
|
|
|
|
request_path = communication_dir_path / "request.json"
|
|
response_path = communication_dir_path / "response.json"
|
|
|
|
# Generate uuid that will be mirrored back to us by command server for
|
|
# sanity checking
|
|
uuid = str(uuid4())
|
|
|
|
request = Request(
|
|
command_id=command_id,
|
|
args=args,
|
|
wait_for_finish=wait_for_finish,
|
|
return_command_output=return_command_output,
|
|
uuid=uuid,
|
|
)
|
|
|
|
# First, write the request to the request file, which makes us the sole
|
|
# owner because all other processes will try to open it with 'x'
|
|
write_request(request, request_path)
|
|
|
|
# We clear the response file if it does exist, though it shouldn't
|
|
if response_path.exists():
|
|
print("WARNING: Found old response file")
|
|
robust_unlink(response_path)
|
|
|
|
# Then, perform keystroke telling application to execute the command in the
|
|
# request file. Because only the active application instance will accept
|
|
# keypresses, we can be sure that the active application instance will be the
|
|
# one to execute the command.
|
|
actions.user.trigger_command_server_command_execution()
|
|
|
|
try:
|
|
decoded_contents = read_json_with_timeout(response_path)
|
|
finally:
|
|
# NB: We remove response file first because we want to do this while we
|
|
# still own the request file
|
|
robust_unlink(response_path)
|
|
robust_unlink(request_path)
|
|
|
|
if decoded_contents["uuid"] != uuid:
|
|
raise Exception("uuids did not match")
|
|
|
|
for warning in decoded_contents["warnings"]:
|
|
print(f"WARNING: {warning}")
|
|
|
|
if decoded_contents["error"] is not None:
|
|
raise Exception(decoded_contents["error"])
|
|
|
|
actions.sleep("25ms")
|
|
|
|
return decoded_contents["returnValue"]
|
|
|
|
|
|
def get_communication_dir_path():
|
|
"""Returns directory that is used by command-server for communication
|
|
|
|
Returns:
|
|
Path: The path to the communication dir
|
|
"""
|
|
suffix = ""
|
|
|
|
# NB: We don't suffix on Windows, because the temp dir is user-specific
|
|
# anyways
|
|
if hasattr(os, "getuid"):
|
|
suffix = f"-{os.getuid()}"
|
|
|
|
return Path(gettempdir()) / f"{actions.user.command_server_directory()}{suffix}"
|
|
|
|
|
|
def robust_unlink(path: Path):
|
|
"""Unlink the given file if it exists, and if we're on windows and it is
|
|
currently in use, just rename it
|
|
|
|
Args:
|
|
path (Path): The path to unlink
|
|
"""
|
|
try:
|
|
path.unlink(missing_ok=True)
|
|
except OSError as e:
|
|
if hasattr(e, "winerror") and e.winerror == 32:
|
|
graveyard_dir = get_communication_dir_path() / "graveyard"
|
|
graveyard_dir.mkdir(parents=True, exist_ok=True)
|
|
graveyard_path = graveyard_dir / str(uuid4())
|
|
print(
|
|
f"WARNING: File {path} was in use when we tried to delete it; "
|
|
f"moving to graveyard at path {graveyard_path}"
|
|
)
|
|
path.rename(graveyard_path)
|
|
else:
|
|
raise e
|
|
|
|
|
|
def read_json_with_timeout(path: Path) -> Any:
|
|
"""Repeatedly tries to read a json object from the given path, waiting
|
|
until there is a trailing new line indicating that the write is complete
|
|
|
|
Args:
|
|
path (str): The path to read from
|
|
|
|
Raises:
|
|
Exception: If we timeout waiting for a response
|
|
|
|
Returns:
|
|
Any: The json-decoded contents of the file
|
|
"""
|
|
timeout_time = time.perf_counter() + RPC_COMMAND_TIMEOUT_SECONDS
|
|
sleep_time = MINIMUM_SLEEP_TIME_SECONDS
|
|
while True:
|
|
try:
|
|
raw_text = path.read_text()
|
|
|
|
if raw_text.endswith("\n"):
|
|
break
|
|
except FileNotFoundError:
|
|
# If not found, keep waiting
|
|
pass
|
|
|
|
actions.sleep(sleep_time)
|
|
|
|
time_left = timeout_time - time.perf_counter()
|
|
|
|
if time_left < 0:
|
|
raise Exception("Timed out waiting for response")
|
|
|
|
# NB: We use minimum sleep time here to ensure that we don't spin with
|
|
# small sleeps due to clock slip
|
|
sleep_time = max(min(sleep_time * 2, time_left), MINIMUM_SLEEP_TIME_SECONDS)
|
|
|
|
return json.loads(raw_text)
|
|
|
|
|
|
@mod.action_class
|
|
class Actions:
|
|
def run_rpc_command(
|
|
command_id: str,
|
|
arg1: Any = NotSet,
|
|
arg2: Any = NotSet,
|
|
arg3: Any = NotSet,
|
|
arg4: Any = NotSet,
|
|
arg5: Any = NotSet,
|
|
):
|
|
"""Execute command via RPC."""
|
|
run_command(
|
|
command_id,
|
|
arg1,
|
|
arg2,
|
|
arg3,
|
|
arg4,
|
|
arg5,
|
|
)
|
|
|
|
def run_rpc_command_and_wait(
|
|
command_id: str,
|
|
arg1: Any = NotSet,
|
|
arg2: Any = NotSet,
|
|
arg3: Any = NotSet,
|
|
arg4: Any = NotSet,
|
|
arg5: Any = NotSet,
|
|
):
|
|
"""Execute command via application command server and wait for command to finish."""
|
|
run_command(
|
|
command_id,
|
|
arg1,
|
|
arg2,
|
|
arg3,
|
|
arg4,
|
|
arg5,
|
|
wait_for_finish=True,
|
|
)
|
|
|
|
def run_rpc_command_get(
|
|
command_id: str,
|
|
arg1: Any = NotSet,
|
|
arg2: Any = NotSet,
|
|
arg3: Any = NotSet,
|
|
arg4: Any = NotSet,
|
|
arg5: Any = NotSet,
|
|
) -> Any:
|
|
"""Execute command via application command server and return command output."""
|
|
return run_command(
|
|
command_id,
|
|
arg1,
|
|
arg2,
|
|
arg3,
|
|
arg4,
|
|
arg5,
|
|
return_command_output=True,
|
|
)
|
|
|
|
def command_server_directory() -> str:
|
|
"""Return the directory of the command server"""
|
|
|
|
def trigger_command_server_command_execution():
|
|
"""Issue keystroke to trigger command server to execute command that
|
|
was written to the file. For internal use only"""
|
|
actions.key("ctrl-shift-f17")
|
|
|
|
def emit_pre_phrase_signal() -> bool:
|
|
"""
|
|
If in an application supporting the command client, returns True
|
|
and touches a file to indicate that a phrase is beginning execution.
|
|
Otherwise does nothing and returns False.
|
|
"""
|
|
return False
|
|
|
|
def did_emit_pre_phrase_signal() -> bool:
|
|
"""Indicates whether the pre-phrase signal was emitted at the start of this phrase"""
|
|
# NB: This action is used by cursorless; please don't delete it :)
|
|
return did_emit_pre_phrase_signal
|
|
|
|
|
|
@mac_ctx.action_class("user")
|
|
class MacUserActions:
|
|
def trigger_command_server_command_execution():
|
|
actions.key("cmd-shift-f17")
|
|
|
|
|
|
@ctx.action_class("user")
|
|
class UserActions:
|
|
def emit_pre_phrase_signal():
|
|
get_signal_path("prePhrase").touch()
|
|
return True
|
|
|
|
|
|
class MissingCommunicationDir(Exception):
|
|
pass
|
|
|
|
|
|
def get_signal_path(name: str) -> Path:
|
|
"""
|
|
Get the path to a signal in the signal subdirectory.
|
|
|
|
Args:
|
|
name (str): The name of the signal
|
|
|
|
Returns:
|
|
Path: The signal path
|
|
"""
|
|
communication_dir_path = get_communication_dir_path()
|
|
|
|
if not communication_dir_path.exists():
|
|
raise MissingCommunicationDir()
|
|
|
|
signal_dir = communication_dir_path / "signals"
|
|
signal_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
return signal_dir / name
|
|
|
|
|
|
def pre_phrase(_: Any):
|
|
try:
|
|
global did_emit_pre_phrase_signal
|
|
|
|
did_emit_pre_phrase_signal = actions.user.emit_pre_phrase_signal()
|
|
except MissingCommunicationDir:
|
|
pass
|
|
|
|
|
|
def post_phrase(_: Any):
|
|
global did_emit_pre_phrase_signal
|
|
did_emit_pre_phrase_signal = False
|
|
|
|
|
|
speech_system.register("pre:phrase", pre_phrase)
|
|
speech_system.register("post:phrase", post_phrase)
|