Skip to content

Commit fb4977f

Browse files
authored
File-based RPC (#3)
* Add nonce for security * Initial working file-based RPC * minor fix * Minor cleanup * more docs * minor * Update README * tweak * Address PR comments * More PR * More fixes * Windows fixes * Switch to userinfo in getCommuncationDir * Minor cleanup
1 parent a72660f commit fb4977f

14 files changed

+418
-201
lines changed

README.md

Lines changed: 54 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
11
# command-server README
22

3-
Creates an http server that exposes a REST api to execute commands.
3+
Adds support for running arbitrary commands via file-based RPC. Designed for
4+
use with voice-control systems such as [Talon](https://talonvoice.com/).
45

56
## Features
67

7-
On startup, spawns an http server listening for commands to execute. Each
8-
running VSCode editor workspace runs its own server, each of which pick a
9-
different random free port on which to listen.
10-
The port of the server for the active editor is written to a file named
11-
`vscode-port` in the operating system's default directory for temporary files.
8+
On startup, creates a directory in the default tmp directory, called
9+
`vscode-command-server-${userName}`, where `${userName}` is the username. Then
10+
waits for the `command-server.runCommand` command to be issued, which will
11+
trigger the command server to read the `request.json` file in the communication
12+
directory, execute the command requested there, and write the response to
13+
`response.json`. Note that we write the JSON response on a single line, with a
14+
trailing newline, so that the client can repeatedly try to read the file until
15+
it finds a final newline to indicate that the write is complete.
1216

13-
Accepts requests of the form:
14-
15-
```http
16-
POST /execute-command HTTP/1.1
17+
Note that the command server will refuse to execute a command if the request file is older than 3 seconds.
1718

19+
Requests look as follows:
20+
```json
1821
{
1922
"commandId": "some-command-id",
2023
"args": [
@@ -23,6 +26,8 @@ POST /execute-command HTTP/1.1
2326
}
2427
```
2528

29+
See `Request` and `Response` [types](src/types.ts) for supported request / response parameters.
30+
2631
Upon receiving the above, this extension would run the command
2732
`some-command-id` with argument `"some-argument"`.
2833

@@ -32,83 +37,49 @@ pass `waitForFinish=true`.
3237
If you'd like the server to wait for the command to finish and then respond
3338
with the command return value encoded as JSON, pass `expectResponse=true`.
3439

35-
Note that the server is bound to `localhost`, so it will only accept commands
36-
from processes running on the same host as VSCode.
37-
3840
### Python example
3941

40-
```py
41-
import json
42-
import requests
43-
from pathlib import Path
44-
from tempfile import gettempdir
45-
46-
47-
port_file_path = Path(gettempdir()) / "vscode-port"
48-
contents = json.loads(port_file_path.read_text())
49-
port = contents["port"]
50-
51-
response = requests.post(
52-
f"http://localhost:{port}/execute-command",
53-
json={
54-
"commandId": "some-command-id",
55-
"args": ["some-argument"],
56-
},
57-
timeout=(0.05, 3.05),
58-
)
59-
response.raise_for_status()
42+
Have a look at
43+
[`command_client.py`](https://github.com/knausj85/knausj_talon/blob/master/apps/vscode/command_client.py)
44+
in the knausj talon repo.
45+
46+
## Commands
47+
Contributes the following commands:
48+
- `command-server.runCommand`: Reads from the requests.json file and executes the given command.
49+
50+
## Keyboard shortcuts
51+
| Key | Command |
52+
| ---------------------------------------------------------------- | -------------------------------- |
53+
| <kbd>Ctrl</kbd>/<kbd>Cmd</kbd> + <kbd>Shift</kbd> + <kbd>Alt</kbd> + <kbd>P</kbd> | Run command |
54+
55+
## Configuration
56+
Contributes the following settings:
57+
58+
### `command-server.allowList`
59+
Allows user to specify the allowed commands using glob syntax, eg:
60+
61+
```json
62+
{
63+
"command-server.allowList": ["workbench.*"]
64+
}
6065
```
6166

62-
## Troubleshooting
67+
Defaults to `["*"]` (allows everything).
68+
69+
### `command-server.denyList`
70+
Allows user to specify the denied commands using glob syntax, eg:
6371

64-
If you're running into issues with commands interleaving with keystrokes, or the extension not responding, the server supports a command `command-server.writePort`, which will cause the extension to update the port and write a monotonically increasing counter variable to the port file. You can run this command (via keyboard shortcut) and then wait for the file to update to ensure you're talking to the right vscode instance and to ensure that the command will not interleave with other keyboard shortcuts issued to VSCode.
65-
66-
Here's some example code for this mode of operation. Note that this assumes
67-
that you have a function `actions.key` that presses the given key (eg
68-
[talon](https://talonvoice.com/)):
69-
70-
```py
71-
import json
72-
import requests
73-
import time
74-
from pathlib import Path
75-
from tempfile import gettempdir
76-
77-
port_file_path = Path(gettempdir()) / "vscode-port"
78-
original_contents = port_file_path.read_text()
79-
80-
# Issue command to VSCode telling it to update the port file. Because only
81-
# the active VSCode instance will accept keypresses, we can be sure that
82-
# the active VSCode instance will be the one to write the port.
83-
if is_mac:
84-
actions.key("cmd-shift-alt-p")
85-
else:
86-
actions.key("ctrl-shift-alt-p")
87-
88-
# Wait for the VSCode instance to update the port file. This generally
89-
# happens within the first millisecond, but we give it 3 seconds just in
90-
# case.
91-
start_time = time.monotonic()
92-
new_contents = port_file_path.read_text()
93-
while original_contents == new_contents:
94-
time.sleep(0.001)
95-
if time.monotonic() - start_time > 3.0:
96-
raise Exception("Timed out waiting for VSCode to update port file")
97-
new_contents = port_file_path.read_text()
98-
99-
port = json.loads(new_contents)["port"]
100-
101-
response = requests.post(
102-
f"http://localhost:{port}/execute-command",
103-
json={
104-
"commandId": "some-command-id",
105-
"args": ["some-argument"],
106-
},
107-
timeout=(0.05, 3.05),
108-
)
109-
response.raise_for_status()
72+
```json
73+
{
74+
"command-server.denyList": ["workbench.*"]
75+
}
11076
```
11177

78+
Defaults to `[]` (doesn't deny anything).
79+
80+
## Troubleshooting
81+
82+
11283
## Known issues
11384

11485
- The server won't respond until the extension is loaded. This may be obvious,
@@ -117,3 +88,6 @@ response.raise_for_status()
11788
editor window until everything is fully loaded.
11889

11990
## Release Notes
91+
92+
### 0.4.0
93+
- Switch to file-based RPC

package.json

100644100755
Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
{
22
"name": "command-server",
33
"displayName": "Command server",
4-
"description": "Exposes an HTTP REST api for command execution",
4+
"description": "Exposes a file-based RPC API for running VSCode commands",
55
"publisher": "pokey",
66
"license": "MIT",
77
"repository": {
88
"type": "git",
99
"url": "https://github.com/pokey/command-server"
1010
},
11-
"version": "0.3.4",
11+
"version": "0.4.0",
1212
"engines": {
1313
"vscode": "^1.53.0"
1414
},
@@ -25,37 +25,60 @@
2525
"contributes": {
2626
"commands": [
2727
{
28-
"command": "command-server.writePort",
29-
"title": "Write port to file"
28+
"command": "command-server.runCommand",
29+
"title": "Read command description from a file and execute the command"
3030
}
3131
],
3232
"keybindings": [
3333
{
34-
"command": "command-server.writePort",
34+
"command": "command-server.runCommand",
3535
"key": "ctrl+shift+alt+p",
3636
"mac": "cmd+shift+alt+p"
3737
}
38-
]
38+
],
39+
"configuration": {
40+
"title": "Command server",
41+
"properties": {
42+
"command-server.allowList": {
43+
"type": "array",
44+
"default": [
45+
"*"
46+
],
47+
"description": "Commands to allow. Supports simple glob syntax"
48+
},
49+
"command-server.denyList": {
50+
"type": "array",
51+
"default": [],
52+
"description": "Commands to deny. Supports simple glob syntax"
53+
}
54+
}
55+
}
3956
},
4057
"scripts": {
4158
"vscode:prepublish": "yarn run compile",
4259
"compile": "tsc -p ./",
4360
"watch": "tsc -watch -p ./",
4461
"pretest": "yarn run compile && yarn run lint",
4562
"lint": "eslint src --ext ts",
46-
"test": "node ./out/test/runTest.js"
63+
"test": "node ./out/test/runTest.js",
64+
"vscode:uninstall": "node ./out/uninstall.js"
4765
},
4866
"devDependencies": {
49-
"@types/vscode": "^1.53.0",
5067
"@types/glob": "^7.1.3",
5168
"@types/mocha": "^8.0.4",
52-
"@types/node": "^12.11.7",
53-
"eslint": "^7.15.0",
69+
"@types/node": "^15.12.1",
70+
"@types/rimraf": "^3.0.0",
71+
"@types/vscode": "^1.53.0",
5472
"@typescript-eslint/eslint-plugin": "^4.9.0",
5573
"@typescript-eslint/parser": "^4.9.0",
74+
"eslint": "^7.15.0",
5675
"glob": "^7.1.6",
5776
"mocha": "^8.1.3",
5877
"typescript": "^4.1.2",
5978
"vscode-test": "^1.4.1"
79+
},
80+
"dependencies": {
81+
"minimatch": "^3.0.4",
82+
"rimraf": "^3.0.2"
6083
}
6184
}

src/commandRunner.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { Minimatch } from "minimatch";
2+
import * as vscode from "vscode";
3+
4+
import { readRequest, writeResponse } from "./io";
5+
import { any } from "./regex";
6+
7+
export default class CommandRunner {
8+
allowRegex!: RegExp;
9+
denyRegex!: RegExp | null;
10+
11+
constructor() {
12+
this.reloadConfiguration = this.reloadConfiguration.bind(this);
13+
this.runCommand = this.runCommand.bind(this);
14+
15+
this.reloadConfiguration();
16+
vscode.workspace.onDidChangeConfiguration(this.reloadConfiguration);
17+
}
18+
19+
reloadConfiguration() {
20+
const allowList = vscode.workspace
21+
.getConfiguration("command-server")
22+
.get<string[]>("allowList")!;
23+
24+
this.allowRegex = any(
25+
...allowList.map((glob) => new Minimatch(glob).makeRe())
26+
);
27+
28+
const denyList = vscode.workspace
29+
.getConfiguration("command-server")
30+
.get<string[]>("denyList")!;
31+
32+
this.denyRegex =
33+
denyList.length === 0
34+
? null
35+
: any(...denyList.map((glob) => new Minimatch(glob).makeRe()));
36+
}
37+
38+
/**
39+
* Reads a command from the request file and executes it. Writes information
40+
* about command execution to the result of the command to the response file,
41+
* If requested, will wait for command to finish, and can also write command
42+
* output to response file. See also documentation for Request / Response
43+
* types.
44+
*/
45+
async runCommand() {
46+
const { commandId, args, uuid, returnCommandOutput, waitForFinish } =
47+
await readRequest();
48+
49+
try {
50+
if (!vscode.window.state.focused) {
51+
throw new Error("This editor is not active");
52+
}
53+
54+
if (!commandId.match(this.allowRegex)) {
55+
throw new Error("Command not in allowList");
56+
}
57+
58+
if (this.denyRegex != null && commandId.match(this.denyRegex)) {
59+
throw new Error("Command in denyList");
60+
}
61+
62+
const commandPromise = vscode.commands.executeCommand(commandId, ...args);
63+
64+
var commandReturnValue = null;
65+
66+
if (returnCommandOutput) {
67+
commandReturnValue = await commandPromise;
68+
} else if (waitForFinish) {
69+
await commandPromise;
70+
}
71+
72+
await writeResponse({
73+
error: null,
74+
uuid,
75+
returnValue: commandReturnValue,
76+
});
77+
} catch (err) {
78+
await writeResponse({
79+
error: err.message,
80+
uuid,
81+
});
82+
}
83+
}
84+
}

src/constants.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// How old a request file needs to be before we declare it stale and are willing
2+
// to remove it
3+
export const STALE_TIMEOUT_MS = 60000;
4+
5+
// The amount of time that client is expected to wait for VSCode to perform a
6+
// command, in seconds
7+
export const VSCODE_COMMAND_TIMEOUT_MS = 3000;

0 commit comments

Comments
 (0)