From 8dc1d6f5497544b642d6b2de33ea80702dd107ba Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Thu, 9 May 2024 16:30:29 +0200 Subject: [PATCH 01/14] Extracting code into separate rpc server --- src/commandRunner.ts | 161 ++++++++------------ src/constants.ts | 7 - src/extension.ts | 67 ++++---- src/fileUtils.ts | 11 -- src/initializeCommunicationDir.ts | 29 ---- src/io.ts | 36 ----- src/paths.ts | 26 ++-- src/rpcServer/RpcServer.ts | 63 ++++++++ src/rpcServer/index.ts | 1 + src/rpcServer/initializeCommunicationDir.ts | 25 +++ src/rpcServer/io.ts | 44 ++++++ src/rpcServer/types.ts | 73 +++++++++ src/rpcServer/upgradeRequest.ts | 32 ++++ src/types.ts | 66 ++------ 14 files changed, 356 insertions(+), 285 deletions(-) delete mode 100644 src/constants.ts delete mode 100644 src/fileUtils.ts delete mode 100644 src/initializeCommunicationDir.ts delete mode 100644 src/io.ts create mode 100644 src/rpcServer/RpcServer.ts create mode 100644 src/rpcServer/index.ts create mode 100644 src/rpcServer/initializeCommunicationDir.ts create mode 100644 src/rpcServer/io.ts create mode 100644 src/rpcServer/types.ts create mode 100644 src/rpcServer/upgradeRequest.ts diff --git a/src/commandRunner.ts b/src/commandRunner.ts index a139aa8..c9d6324 100644 --- a/src/commandRunner.ts +++ b/src/commandRunner.ts @@ -1,113 +1,76 @@ -import { open } from "fs/promises"; import { Minimatch } from "minimatch"; import * as vscode from "vscode"; - -import { readRequest, writeResponse } from "./io"; -import { getResponsePath } from "./paths"; +import { getCommunicationDirPath } from "./paths"; import { any } from "./regex"; -import { Request } from "./types"; +import { RpcServer } from "./rpcServer"; +import type { Payload } from "./types"; export default class CommandRunner { - allowRegex!: RegExp; - denyRegex!: RegExp | null; - backgroundWindowProtection!: boolean; - - constructor() { - this.reloadConfiguration = this.reloadConfiguration.bind(this); - this.runCommand = this.runCommand.bind(this); - - this.reloadConfiguration(); - vscode.workspace.onDidChangeConfiguration(this.reloadConfiguration); - } - - reloadConfiguration() { - const allowList = vscode.workspace - .getConfiguration("command-server") - .get("allowList")!; - - this.allowRegex = any( - ...allowList.map((glob) => new Minimatch(glob).makeRe()) - ); - - const denyList = vscode.workspace - .getConfiguration("command-server") - .get("denyList")!; - - this.denyRegex = - denyList.length === 0 - ? null - : any(...denyList.map((glob) => new Minimatch(glob).makeRe())); - - this.backgroundWindowProtection = vscode.workspace - .getConfiguration("command-server") - .get("backgroundWindowProtection")!; - } - - /** - * Reads a command from the request file and executes it. Writes information - * about command execution to the result of the command to the response file, - * If requested, will wait for command to finish, and can also write command - * output to response file. See also documentation for Request / Response - * types. - */ - async runCommand() { - const responseFile = await open(getResponsePath(), "wx"); - - let request: Request; - - try { - request = await readRequest(); - } catch (err) { - await responseFile.close(); - throw err; + allowRegex!: RegExp; + denyRegex!: RegExp | null; + backgroundWindowProtection!: boolean; + rpc: RpcServer; + + constructor() { + this.reloadConfiguration = this.reloadConfiguration.bind(this); + this.runCommand = this.runCommand.bind(this); + this.rpc = new RpcServer(getCommunicationDirPath()); + + this.reloadConfiguration(); + vscode.workspace.onDidChangeConfiguration(this.reloadConfiguration); } - const { commandId, args, uuid, returnCommandOutput, waitForFinish } = - request; - - const warnings = []; + reloadConfiguration() { + const allowList = vscode.workspace + .getConfiguration("command-server") + .get("allowList")!; - try { - if (!vscode.window.state.focused) { - if (this.backgroundWindowProtection) { - throw new Error("This editor is not active"); - } else { - warnings.push("This editor is not active"); - } - } + this.allowRegex = any( + ...allowList.map((glob) => new Minimatch(glob).makeRe()) + ); - if (!commandId.match(this.allowRegex)) { - throw new Error("Command not in allowList"); - } + const denyList = vscode.workspace + .getConfiguration("command-server") + .get("denyList")!; - if (this.denyRegex != null && commandId.match(this.denyRegex)) { - throw new Error("Command in denyList"); - } + this.denyRegex = + denyList.length === 0 + ? null + : any(...denyList.map((glob) => new Minimatch(glob).makeRe())); - const commandPromise = vscode.commands.executeCommand(commandId, ...args); - - let commandReturnValue = null; - - if (returnCommandOutput) { - commandReturnValue = await commandPromise; - } else if (waitForFinish) { - await commandPromise; - } - - await writeResponse(responseFile, { - error: null, - uuid, - returnValue: commandReturnValue, - warnings, - }); - } catch (err) { - await writeResponse(responseFile, { - error: (err as Error).message, - uuid, - warnings, - }); + this.backgroundWindowProtection = vscode.workspace + .getConfiguration("command-server") + .get("backgroundWindowProtection")!; } - await responseFile.close(); - } + /** + * Reads a command from the request file and executes it. Writes information + * about command execution to the result of the command to the response file, + * If requested, will wait for command to finish, and can also write command + * output to response file. See also documentation for Request / Response + * types. + */ + async runCommand() { + this.rpc.executeRequest(({ commandId, args }) => { + if (!vscode.window.state.focused) { + if (this.backgroundWindowProtection) { + throw new Error("This editor is not active"); + } else { + // TODO: How should we handle this? + // warnings.push("This editor is not active"); + console.warn("This editor is not active"); + } + } + + if (!commandId.match(this.allowRegex)) { + throw new Error("Command not in allowList"); + } + + if (this.denyRegex != null && commandId.match(this.denyRegex)) { + throw new Error("Command in denyList"); + } + + return vscode.commands.executeCommand(commandId, ...args); + }); + } } diff --git a/src/constants.ts b/src/constants.ts deleted file mode 100644 index 7e9563f..0000000 --- a/src/constants.ts +++ /dev/null @@ -1,7 +0,0 @@ -// How old a request file needs to be before we declare it stale and are willing -// to remove it -export const STALE_TIMEOUT_MS = 60000; - -// The amount of time that client is expected to wait for VSCode to perform a -// command, in seconds -export const VSCODE_COMMAND_TIMEOUT_MS = 3000; diff --git a/src/extension.ts b/src/extension.ts index 9d1248a..29da835 100755 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,48 +1,45 @@ import * as vscode from "vscode"; import CommandRunner from "./commandRunner"; -import { initializeCommunicationDir } from "./initializeCommunicationDir"; import { getInboundSignal } from "./signal"; import { FocusedElementType } from "./types"; export function activate(context: vscode.ExtensionContext) { - initializeCommunicationDir(); + const commandRunner = new CommandRunner(); + let focusedElementType: FocusedElementType | undefined; - const commandRunner = new CommandRunner(); - let focusedElementType: FocusedElementType | undefined; + context.subscriptions.push( + vscode.commands.registerCommand( + "command-server.runCommand", + (focusedElementType_?: FocusedElementType) => { + focusedElementType = focusedElementType_; + return commandRunner.runCommand(); + } + ), + vscode.commands.registerCommand( + "command-server.getFocusedElementType", + () => focusedElementType ?? null + ) + ); - context.subscriptions.push( - vscode.commands.registerCommand( - "command-server.runCommand", - (focusedElementType_?: FocusedElementType) => { - focusedElementType = focusedElementType_; - return commandRunner.runCommand(); - } - ), - vscode.commands.registerCommand( - "command-server.getFocusedElementType", - () => focusedElementType ?? null - ) - ); + return { + /** + * The type of the focused element in vscode at the moment of the command being executed. + */ + getFocusedElementType: () => focusedElementType, - return { - /** - * The type of the focused element in vscode at the moment of the command being executed. - */ - getFocusedElementType: () => focusedElementType, - - /** - * These signals can be used as a form of IPC to indicate that an event has - * occurred. - */ - signals: { - /** - * This signal is emitted by the voice engine to indicate that a phrase has - * just begun execution. - */ - prePhrase: getInboundSignal("prePhrase"), - }, - }; + /** + * These signals can be used as a form of IPC to indicate that an event has + * occurred. + */ + signals: { + /** + * This signal is emitted by the voice engine to indicate that a phrase has + * just begun execution. + */ + prePhrase: getInboundSignal("prePhrase"), + }, + }; } // this method is called when your extension is deactivated diff --git a/src/fileUtils.ts b/src/fileUtils.ts deleted file mode 100644 index 867408e..0000000 --- a/src/fileUtils.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { FileHandle } from "fs/promises"; - -/** - * Writes stringified JSON. - * Appends newline so that other side knows when it is done - * @param path Output path - * @param body Body to stringify and write - */ -export async function writeJSON(file: FileHandle, body: any) { - await file.write(`${JSON.stringify(body)}\n`); -} diff --git a/src/initializeCommunicationDir.ts b/src/initializeCommunicationDir.ts deleted file mode 100644 index d165e89..0000000 --- a/src/initializeCommunicationDir.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { mkdirSync, lstatSync } from "fs"; -import { S_IWOTH } from "constants"; -import { - getCommunicationDirPath, -} from "./paths"; -import { userInfo } from "os"; - -export function initializeCommunicationDir() { - const communicationDirPath = getCommunicationDirPath(); - - console.debug(`Creating communication dir ${communicationDirPath}`); - mkdirSync(communicationDirPath, { recursive: true, mode: 0o770 }); - - const stats = lstatSync(communicationDirPath); - - const info = userInfo(); - - if ( - !stats.isDirectory() || - stats.isSymbolicLink() || - stats.mode & S_IWOTH || - // On Windows, uid < 0, so we don't worry about it for simplicity - (info.uid >= 0 && stats.uid !== info.uid) - ) { - throw new Error( - `Refusing to proceed because of invalid communication dir ${communicationDirPath}` - ); - } -} diff --git a/src/io.ts b/src/io.ts deleted file mode 100644 index 919c9d1..0000000 --- a/src/io.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { FileHandle, readFile, stat } from "fs/promises"; -import { VSCODE_COMMAND_TIMEOUT_MS } from "./constants"; -import { getRequestPath } from "./paths"; -import { Request, Response } from "./types"; -import { writeJSON } from "./fileUtils"; - -/** - * Reads the JSON-encoded request from the request file, unlinking the file - * after reading. - * @returns A promise that resolves to a Response object - */ -export async function readRequest(): Promise { - const requestPath = getRequestPath(); - - const stats = await stat(requestPath); - const request = JSON.parse(await readFile(requestPath, "utf-8")); - - if ( - Math.abs(stats.mtimeMs - new Date().getTime()) > VSCODE_COMMAND_TIMEOUT_MS - ) { - throw new Error( - "Request file is older than timeout; refusing to execute command" - ); - } - - return request; -} - -/** - * Writes the response to the response file as JSON. - * @param file The file to write to - * @param response The response object to JSON-encode and write to disk - */ -export async function writeResponse(file: FileHandle, response: Response) { - await writeJSON(file, response); -} diff --git a/src/paths.ts b/src/paths.ts index 237c110..642885f 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -1,24 +1,22 @@ import { tmpdir, userInfo } from "os"; import { join } from "path"; -export function getCommunicationDirPath() { - const info = userInfo(); +const comDir = (() => { + const info = userInfo(); - // NB: On Windows, uid < 0, and the tmpdir is user-specific, so we don't - // bother with a suffix - const suffix = info.uid >= 0 ? `-${info.uid}` : ""; + // NB: On Windows, uid < 0, and the tmpdir is user-specific, so we don't + // bother with a suffix + const suffix = info.uid >= 0 ? `-${info.uid}` : ""; - return join(tmpdir(), `vscode-command-server${suffix}`); -} + return join(tmpdir(), `vscode-command-server${suffix}`); +})(); -export function getSignalDirPath(): string { - return join(getCommunicationDirPath(), "signals"); -} +const signalDir = join(comDir, "signals"); -export function getRequestPath() { - return join(getCommunicationDirPath(), "request.json"); +export function getCommunicationDirPath() { + return comDir; } -export function getResponsePath() { - return join(getCommunicationDirPath(), "response.json"); +export function getSignalDirPath(): string { + return signalDir; } diff --git a/src/rpcServer/RpcServer.ts b/src/rpcServer/RpcServer.ts new file mode 100644 index 0000000..d4eee2a --- /dev/null +++ b/src/rpcServer/RpcServer.ts @@ -0,0 +1,63 @@ +import * as fs from "fs/promises"; +import * as path from "path"; +import { initializeCommunicationDir } from "./initializeCommunicationDir"; +import { readRequest, writeResponse } from "./io"; +import type { RequestLatest } from "./types"; +import { upgradeRequest } from "./upgradeRequest"; + +export class RpcServer { + private requestPath: string; + private responsePath: string; + + constructor(private dirPath: string) { + this.requestPath = path.join(this.dirPath, "request.json"); + this.responsePath = path.join(this.dirPath, "response.json"); + initializeCommunicationDir(this.dirPath); + } + + async executeRequest(callback: (payload: T) => unknown) { + const responseFile = await fs.open(this.requestPath, "wx"); + + let request: RequestLatest; + + try { + const requestInput = await readRequest(this.requestPath); + request = upgradeRequest(requestInput); + } catch (err) { + await responseFile.close(); + throw err; + } + + const { uuid, returnCommandOutput, waitForFinish, payload } = request; + + // TODO: Do we need this? + const warnings: string[] = []; + + try { + const commandPromise = Promise.resolve(callback(payload as T)); + + let commandReturnValue = null; + + if (returnCommandOutput) { + commandReturnValue = await commandPromise; + } else if (waitForFinish) { + await commandPromise; + } + + await writeResponse(responseFile, { + uuid, + returnValue: commandReturnValue, + error: null, + warnings, + }); + } catch (err) { + await writeResponse(responseFile, { + uuid, + error: (err as Error).message, + warnings, + }); + } + + await responseFile.close(); + } +} diff --git a/src/rpcServer/index.ts b/src/rpcServer/index.ts new file mode 100644 index 0000000..ced54d0 --- /dev/null +++ b/src/rpcServer/index.ts @@ -0,0 +1 @@ +export * from "./RpcServer"; diff --git a/src/rpcServer/initializeCommunicationDir.ts b/src/rpcServer/initializeCommunicationDir.ts new file mode 100644 index 0000000..366350e --- /dev/null +++ b/src/rpcServer/initializeCommunicationDir.ts @@ -0,0 +1,25 @@ +import { S_IWOTH } from "constants"; +import { lstatSync, mkdirSync } from "fs"; +import { userInfo } from "os"; + +export function initializeCommunicationDir(dirPath: string) { + console.debug(`Creating communication dir ${dirPath}`); + + mkdirSync(dirPath, { recursive: true, mode: 0o770 }); + + const stats = lstatSync(dirPath); + + const info = userInfo(); + + if ( + !stats.isDirectory() || + stats.isSymbolicLink() || + stats.mode & S_IWOTH || + // On Windows, uid < 0, so we don't worry about it for simplicity + (info.uid >= 0 && stats.uid !== info.uid) + ) { + throw new Error( + `Refusing to proceed because of invalid communication dir ${dirPath}` + ); + } +} diff --git a/src/rpcServer/io.ts b/src/rpcServer/io.ts new file mode 100644 index 0000000..37d8655 --- /dev/null +++ b/src/rpcServer/io.ts @@ -0,0 +1,44 @@ +import { FileHandle, readFile, stat } from "fs/promises"; +import { Request, Response } from "./types"; + +// The amount of time that client is expected to wait for the server to perform a +// command, in milliseconds. +export const COMMAND_TIMEOUT_MS = 3000; + +/** + * Reads the JSON-encoded request from the request file, unlinking the file + * after reading. + * @returns A promise that resolves to a Response object + */ +export async function readRequest(requestPath: string): Promise { + const stats = await stat(requestPath); + const content = await readFile(requestPath, "utf-8"); + const request = JSON.parse(content); + + if (Math.abs(stats.mtimeMs - Date.now()) > COMMAND_TIMEOUT_MS) { + throw new Error( + "Request file is older than timeout; refusing to execute command" + ); + } + + return request; +} + +/** + * Writes the response to the response file as JSON. + * @param file The file to write to + * @param response The response object to JSON-encode and write to disk + */ +export async function writeResponse(file: FileHandle, response: Response) { + await writeJSON(file, response); +} + +/** + * Writes stringified JSON. + * Appends newline so that other side knows when it is done + * @param path Output path + * @param body Body to stringify and write + */ +async function writeJSON(file: FileHandle, body: any) { + await file.write(`${JSON.stringify(body)}\n`); +} diff --git a/src/rpcServer/types.ts b/src/rpcServer/types.ts new file mode 100644 index 0000000..40ef8cc --- /dev/null +++ b/src/rpcServer/types.ts @@ -0,0 +1,73 @@ +interface RequestBase { + /** + * A uuid that will be written to the response file for sanity checking + * client-side + */ + uuid: string; + + /** + * A boolean indicating if we should return the output of the command + */ + returnCommandOutput: boolean; + + /** + * A boolean indicating if we should await the command to ensure it is + * complete. This behaviour is desirable for some commands and not others. + * For most commands it is ok, and can remove race conditions, but for + * some commands, such as ones that show a quick picker, it can hang the + * client + */ + waitForFinish: boolean; +} + +export interface RequestV0 extends RequestBase { + /** + * The id of the command to run + */ + commandId: string; + + /** + * Arguments to the command, if any + */ + args: any[]; +} + +export interface RequestV1 extends RequestBase { + /** + * The version of the request API + */ + version: 1; + + /** + * The payload/body of the request + */ + payload: unknown; +} + +export type Request = RequestV0 | RequestV1; + +export const LATEST_REQUEST_VERSION = 1 as const; + +export type RequestLatest = RequestV1; + +export interface Response { + /** + * The uuid passed into the response for sanity checking client-side + */ + uuid: string; + + /** + * The return value of the command, if requested. + */ + returnValue?: unknown; + + /** + * Any error encountered or null if successful + */ + error: string | null; + + /** + * A list of warnings issued when running the command + */ + warnings: string[]; +} diff --git a/src/rpcServer/upgradeRequest.ts b/src/rpcServer/upgradeRequest.ts new file mode 100644 index 0000000..1a16160 --- /dev/null +++ b/src/rpcServer/upgradeRequest.ts @@ -0,0 +1,32 @@ +import { LATEST_REQUEST_VERSION } from "./types"; +import type { Request, RequestLatest } from "./types"; + +export function upgradeRequest(request: Request): RequestLatest { + if (!("version" in request)) { + return upgradeRequest({ + version: 1, + uuid: request.uuid, + returnCommandOutput: request.returnCommandOutput, + waitForFinish: request.waitForFinish, + payload: { + commandId: request.commandId, + args: request.args, + }, + }); + } + + while (request.version < LATEST_REQUEST_VERSION) { + switch (request.version) { + default: + throw new Error( + `Can't upgrade from unknown version: ${request.version}` + ); + } + } + + if (request.version !== LATEST_REQUEST_VERSION) { + throw Error(`Request is not latest version`); + } + + return request; +} diff --git a/src/types.ts b/src/types.ts index d022aa7..00536bb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,58 +1,16 @@ -export interface Request { - /** - * The id of the command to run - */ - commandId: string; - - /** - * A uuid that will be written to the response file for sanity checking - * client-side - */ - uuid: string; - - /** - * Arguments to the command, if any - */ - args: any[]; - - /** - * A boolean indicating if we should return the output of the command - */ - returnCommandOutput: boolean; - - /** - * A boolean indicating if we should await the command to ensure it is - * complete. This behaviour is desirable for some commands and not others. - * For most commands it is ok, and can remove race conditions, but for - * some commands, such as ones that show a quick picker, it can hang the - * client - */ - waitForFinish: boolean; -} - -export interface Response { - /** - * The return value of the command, if requested. - */ - returnValue?: any; - - /** - * The uuid passed into the response for sanity checking client-side - */ - uuid: string; - - /** - * Any error encountered or null if successful - */ - error: string | null; - - /** - * A list of warnings issued when running the command - */ - warnings: string[]; -} - /** * The type of the focused element in vscode at the moment of the command being executed. */ export type FocusedElementType = "textEditor" | "terminal"; + +export interface Payload { + /** + * The id of the command to run + */ + commandId: string; + + /** + * Arguments to the command, if any + */ + args: any[]; +} From 3fb600c074d6d8c390c76c98c6230366cabd10df Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Thu, 9 May 2024 16:55:00 +0200 Subject: [PATCH 02/14] clean up --- src/commandRunner.ts | 6 +++--- src/extension.ts | 5 +++++ src/paths.ts | 18 ++---------------- src/rpcServer/getCommunicationDirPath.ts | 16 ++++++++++++++++ src/rpcServer/index.ts | 2 ++ src/uninstall.ts | 4 ++-- 6 files changed, 30 insertions(+), 21 deletions(-) create mode 100644 src/rpcServer/getCommunicationDirPath.ts diff --git a/src/commandRunner.ts b/src/commandRunner.ts index f97e49f..d562ecd 100644 --- a/src/commandRunner.ts +++ b/src/commandRunner.ts @@ -1,7 +1,7 @@ import { Minimatch } from "minimatch"; import * as vscode from "vscode"; import { any } from "./regex"; -import { RpcServer } from "./rpcServer"; +import { RpcServer, getCommunicationDirPath } from "./rpcServer"; import type { Payload } from "./types"; export default class CommandRunner { @@ -10,10 +10,10 @@ export default class CommandRunner { private backgroundWindowProtection!: boolean; private rpc: RpcServer; - constructor(dir: string) { + constructor() { this.reloadConfiguration = this.reloadConfiguration.bind(this); this.runCommand = this.runCommand.bind(this); - this.rpc = new RpcServer(dir); + this.rpc = new RpcServer(getCommunicationDirPath()); this.reloadConfiguration(); vscode.workspace.onDidChangeConfiguration(this.reloadConfiguration); diff --git a/src/extension.ts b/src/extension.ts index 4e87398..4c2da76 100755 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,9 +1,14 @@ import * as vscode from "vscode"; import CommandRunner from "./commandRunner"; +import { + getCommunicationDirPath, + initializeCommunicationDir, +} from "./rpcServer"; import { getInboundSignal } from "./signal"; import { FocusedElementType } from "./types"; export async function activate(context: vscode.ExtensionContext) { + initializeCommunicationDir(getCommunicationDirPath()); const commandRunner = new CommandRunner(); let focusedElementType: FocusedElementType | undefined; diff --git a/src/paths.ts b/src/paths.ts index 642885f..9b013aa 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -1,21 +1,7 @@ -import { tmpdir, userInfo } from "os"; import { join } from "path"; +import { getCommunicationDirPath } from "./rpcServer"; -const comDir = (() => { - const info = userInfo(); - - // NB: On Windows, uid < 0, and the tmpdir is user-specific, so we don't - // bother with a suffix - const suffix = info.uid >= 0 ? `-${info.uid}` : ""; - - return join(tmpdir(), `vscode-command-server${suffix}`); -})(); - -const signalDir = join(comDir, "signals"); - -export function getCommunicationDirPath() { - return comDir; -} +const signalDir = join(getCommunicationDirPath(), "signals"); export function getSignalDirPath(): string { return signalDir; diff --git a/src/rpcServer/getCommunicationDirPath.ts b/src/rpcServer/getCommunicationDirPath.ts new file mode 100644 index 0000000..6f75f48 --- /dev/null +++ b/src/rpcServer/getCommunicationDirPath.ts @@ -0,0 +1,16 @@ +import { tmpdir, userInfo } from "os"; +import * as path from "path"; + +const comDir = (() => { + const info = userInfo(); + + // NB: On Windows, uid < 0, and the tmpdir is user-specific, so we don't + // bother with a suffix + const suffix = info.uid >= 0 ? `-${info.uid}` : ""; + + return path.join(tmpdir(), `vscode-command-server${suffix}`); +})(); + +export function getCommunicationDirPath() { + return comDir; +} diff --git a/src/rpcServer/index.ts b/src/rpcServer/index.ts index ced54d0..3bed043 100644 --- a/src/rpcServer/index.ts +++ b/src/rpcServer/index.ts @@ -1 +1,3 @@ export * from "./RpcServer"; +export * from "./getCommunicationDirPath"; +export * from "./initializeCommunicationDir"; diff --git a/src/uninstall.ts b/src/uninstall.ts index bdb6eee..7ea56b4 100755 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -1,8 +1,8 @@ -import { getCommunicationDirPath } from "./paths"; import { sync as rimrafSync } from "rimraf"; +import { getCommunicationDirPath } from "./rpcServer"; function main() { - rimrafSync(getCommunicationDirPath(), { disableGlob: true }); + rimrafSync(getCommunicationDirPath(), { disableGlob: true }); } main(); From 247c6fa04abb5cdbac0d2900a7c9785a6fd49089 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Thu, 9 May 2024 17:00:26 +0200 Subject: [PATCH 03/14] more clean up --- src/commandRunner.ts | 3 ++- src/extension.ts | 6 ++---- src/paths.ts | 9 +++++++-- ...cationDirPath.ts => calculateCommunicationDirPath.ts} | 8 ++------ src/rpcServer/index.ts | 2 +- src/uninstall.ts | 2 +- 6 files changed, 15 insertions(+), 15 deletions(-) rename src/rpcServer/{getCommunicationDirPath.ts => calculateCommunicationDirPath.ts} (62%) diff --git a/src/commandRunner.ts b/src/commandRunner.ts index d562ecd..d5b6fda 100644 --- a/src/commandRunner.ts +++ b/src/commandRunner.ts @@ -1,7 +1,8 @@ import { Minimatch } from "minimatch"; import * as vscode from "vscode"; +import { getCommunicationDirPath } from "./paths"; import { any } from "./regex"; -import { RpcServer, getCommunicationDirPath } from "./rpcServer"; +import { RpcServer } from "./rpcServer"; import type { Payload } from "./types"; export default class CommandRunner { diff --git a/src/extension.ts b/src/extension.ts index 4c2da76..bc91e73 100755 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,9 +1,7 @@ import * as vscode from "vscode"; import CommandRunner from "./commandRunner"; -import { - getCommunicationDirPath, - initializeCommunicationDir, -} from "./rpcServer"; +import { getCommunicationDirPath } from "./paths"; +import { initializeCommunicationDir } from "./rpcServer"; import { getInboundSignal } from "./signal"; import { FocusedElementType } from "./types"; diff --git a/src/paths.ts b/src/paths.ts index 9b013aa..9b39112 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -1,7 +1,12 @@ import { join } from "path"; -import { getCommunicationDirPath } from "./rpcServer"; +import { calculateCommunicationDirPath } from "./rpcServer"; -const signalDir = join(getCommunicationDirPath(), "signals"); +const comDir = calculateCommunicationDirPath("vscode-command-server"); +const signalDir = join(comDir, "signals"); + +export function getCommunicationDirPath(): string { + return comDir; +} export function getSignalDirPath(): string { return signalDir; diff --git a/src/rpcServer/getCommunicationDirPath.ts b/src/rpcServer/calculateCommunicationDirPath.ts similarity index 62% rename from src/rpcServer/getCommunicationDirPath.ts rename to src/rpcServer/calculateCommunicationDirPath.ts index 6f75f48..0755922 100644 --- a/src/rpcServer/getCommunicationDirPath.ts +++ b/src/rpcServer/calculateCommunicationDirPath.ts @@ -1,16 +1,12 @@ import { tmpdir, userInfo } from "os"; import * as path from "path"; -const comDir = (() => { +export function calculateCommunicationDirPath(name: string) { const info = userInfo(); // NB: On Windows, uid < 0, and the tmpdir is user-specific, so we don't // bother with a suffix const suffix = info.uid >= 0 ? `-${info.uid}` : ""; - return path.join(tmpdir(), `vscode-command-server${suffix}`); -})(); - -export function getCommunicationDirPath() { - return comDir; + return path.join(tmpdir(), `${name}${suffix}`); } diff --git a/src/rpcServer/index.ts b/src/rpcServer/index.ts index 3bed043..f6029c5 100644 --- a/src/rpcServer/index.ts +++ b/src/rpcServer/index.ts @@ -1,3 +1,3 @@ export * from "./RpcServer"; -export * from "./getCommunicationDirPath"; +export * from "./calculateCommunicationDirPath"; export * from "./initializeCommunicationDir"; diff --git a/src/uninstall.ts b/src/uninstall.ts index 7ea56b4..b666b90 100755 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -1,5 +1,5 @@ import { sync as rimrafSync } from "rimraf"; -import { getCommunicationDirPath } from "./rpcServer"; +import { getCommunicationDirPath } from "./paths"; function main() { rimrafSync(getCommunicationDirPath(), { disableGlob: true }); From 01483b2373eedda40f1f16ad8a6e5f5b93924951 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Thu, 9 May 2024 17:03:10 +0200 Subject: [PATCH 04/14] update --- src/rpcServer/RpcServer.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/rpcServer/RpcServer.ts b/src/rpcServer/RpcServer.ts index d4eee2a..9f90ee6 100644 --- a/src/rpcServer/RpcServer.ts +++ b/src/rpcServer/RpcServer.ts @@ -12,7 +12,6 @@ export class RpcServer { constructor(private dirPath: string) { this.requestPath = path.join(this.dirPath, "request.json"); this.responsePath = path.join(this.dirPath, "response.json"); - initializeCommunicationDir(this.dirPath); } async executeRequest(callback: (payload: T) => unknown) { From 9aa448774afc04868bd3c71c2d0378e6fd4963d8 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Thu, 9 May 2024 17:05:11 +0200 Subject: [PATCH 05/14] Added await --- src/commandRunner.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commandRunner.ts b/src/commandRunner.ts index d5b6fda..ce5c290 100644 --- a/src/commandRunner.ts +++ b/src/commandRunner.ts @@ -51,7 +51,7 @@ export default class CommandRunner { * types. */ async runCommand() { - this.rpc.executeRequest(({ commandId, args }) => { + await this.rpc.executeRequest(({ commandId, args }) => { if (!vscode.window.state.focused) { if (this.backgroundWindowProtection) { throw new Error("This editor is not active"); From 68ea9105414da635596164ae3c5bccaafe487b60 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Fri, 10 May 2024 10:36:50 +0200 Subject: [PATCH 06/14] cleanup --- src/commandRunner.ts | 45 +++++++++++-------- src/paths.ts | 4 +- src/rpcServer/RpcServer.ts | 8 ++-- ...nDirPath.ts => getCommunicationDirPath.ts} | 4 +- src/rpcServer/index.ts | 2 +- 5 files changed, 36 insertions(+), 27 deletions(-) rename src/rpcServer/{calculateCommunicationDirPath.ts => getCommunicationDirPath.ts} (69%) diff --git a/src/commandRunner.ts b/src/commandRunner.ts index ce5c290..6f7a94f 100644 --- a/src/commandRunner.ts +++ b/src/commandRunner.ts @@ -14,7 +14,12 @@ export default class CommandRunner { constructor() { this.reloadConfiguration = this.reloadConfiguration.bind(this); this.runCommand = this.runCommand.bind(this); - this.rpc = new RpcServer(getCommunicationDirPath()); + this.executeRequest = this.executeRequest.bind(this); + + this.rpc = new RpcServer( + getCommunicationDirPath(), + this.executeRequest + ); this.reloadConfiguration(); vscode.workspace.onDidChangeConfiguration(this.reloadConfiguration); @@ -50,27 +55,29 @@ export default class CommandRunner { * output to response file. See also documentation for Request / Response * types. */ - async runCommand() { - await this.rpc.executeRequest(({ commandId, args }) => { - if (!vscode.window.state.focused) { - if (this.backgroundWindowProtection) { - throw new Error("This editor is not active"); - } else { - // TODO: How should we handle this? - // warnings.push("This editor is not active"); - console.warn("This editor is not active"); - } - } + runCommand(): Promise { + return this.rpc.executeRequest(); + } - if (!commandId.match(this.allowRegex)) { - throw new Error("Command not in allowList"); + private async executeRequest({ commandId, args }: Payload) { + if (!vscode.window.state.focused) { + if (this.backgroundWindowProtection) { + throw new Error("This editor is not active"); + } else { + // TODO: How should we handle this? + // warnings.push("This editor is not active"); + console.warn("This editor is not active"); } + } - if (this.denyRegex != null && commandId.match(this.denyRegex)) { - throw new Error("Command in denyList"); - } + if (!commandId.match(this.allowRegex)) { + throw new Error("Command not in allowList"); + } + + if (this.denyRegex != null && commandId.match(this.denyRegex)) { + throw new Error("Command in denyList"); + } - return vscode.commands.executeCommand(commandId, ...args); - }); + return vscode.commands.executeCommand(commandId, ...args); } } diff --git a/src/paths.ts b/src/paths.ts index 9b39112..18058b8 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -1,7 +1,7 @@ import { join } from "path"; -import { calculateCommunicationDirPath } from "./rpcServer"; +import * as rpcServer from "./rpcServer"; -const comDir = calculateCommunicationDirPath("vscode-command-server"); +const comDir = rpcServer.getCommunicationDirPath("vscode-command-server"); const signalDir = join(comDir, "signals"); export function getCommunicationDirPath(): string { diff --git a/src/rpcServer/RpcServer.ts b/src/rpcServer/RpcServer.ts index 9f90ee6..1a40a9c 100644 --- a/src/rpcServer/RpcServer.ts +++ b/src/rpcServer/RpcServer.ts @@ -8,13 +8,15 @@ import { upgradeRequest } from "./upgradeRequest"; export class RpcServer { private requestPath: string; private responsePath: string; + private callback: (payload: T) => unknown; - constructor(private dirPath: string) { + constructor(private dirPath: string, callback: (payload: T) => unknown) { this.requestPath = path.join(this.dirPath, "request.json"); this.responsePath = path.join(this.dirPath, "response.json"); + this.callback = callback; } - async executeRequest(callback: (payload: T) => unknown) { + async executeRequest() { const responseFile = await fs.open(this.requestPath, "wx"); let request: RequestLatest; @@ -33,7 +35,7 @@ export class RpcServer { const warnings: string[] = []; try { - const commandPromise = Promise.resolve(callback(payload as T)); + const commandPromise = Promise.resolve(this.callback(payload as T)); let commandReturnValue = null; diff --git a/src/rpcServer/calculateCommunicationDirPath.ts b/src/rpcServer/getCommunicationDirPath.ts similarity index 69% rename from src/rpcServer/calculateCommunicationDirPath.ts rename to src/rpcServer/getCommunicationDirPath.ts index 0755922..28d2327 100644 --- a/src/rpcServer/calculateCommunicationDirPath.ts +++ b/src/rpcServer/getCommunicationDirPath.ts @@ -1,12 +1,12 @@ import { tmpdir, userInfo } from "os"; import * as path from "path"; -export function calculateCommunicationDirPath(name: string) { +export function getCommunicationDirPath(dirName: string) { const info = userInfo(); // NB: On Windows, uid < 0, and the tmpdir is user-specific, so we don't // bother with a suffix const suffix = info.uid >= 0 ? `-${info.uid}` : ""; - return path.join(tmpdir(), `${name}${suffix}`); + return path.join(tmpdir(), `${dirName}${suffix}`); } diff --git a/src/rpcServer/index.ts b/src/rpcServer/index.ts index f6029c5..3bed043 100644 --- a/src/rpcServer/index.ts +++ b/src/rpcServer/index.ts @@ -1,3 +1,3 @@ export * from "./RpcServer"; -export * from "./calculateCommunicationDirPath"; +export * from "./getCommunicationDirPath"; export * from "./initializeCommunicationDir"; From 44ca57ffec6e7e82a8b207bdb137301660e3b37c Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Fri, 10 May 2024 10:41:42 +0200 Subject: [PATCH 07/14] ordering --- src/rpcServer/RpcServer.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/rpcServer/RpcServer.ts b/src/rpcServer/RpcServer.ts index 1a40a9c..1f67030 100644 --- a/src/rpcServer/RpcServer.ts +++ b/src/rpcServer/RpcServer.ts @@ -47,15 +47,15 @@ export class RpcServer { await writeResponse(responseFile, { uuid, - returnValue: commandReturnValue, - error: null, warnings, + error: null, + returnValue: commandReturnValue, }); } catch (err) { await writeResponse(responseFile, { uuid, - error: (err as Error).message, warnings, + error: (err as Error).message, }); } From 08daa954f42475322a8bccee9859e249f3f0af61 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Mon, 13 May 2024 14:10:08 +0200 Subject: [PATCH 08/14] Added options object with warn function --- src/commandRunner.ts | 10 ++++++---- src/rpcServer/RpcServer.ts | 20 ++++++++++++++------ src/rpcServer/types.ts | 4 ++++ 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/commandRunner.ts b/src/commandRunner.ts index 6f7a94f..4be5dbc 100644 --- a/src/commandRunner.ts +++ b/src/commandRunner.ts @@ -3,6 +3,7 @@ import * as vscode from "vscode"; import { getCommunicationDirPath } from "./paths"; import { any } from "./regex"; import { RpcServer } from "./rpcServer"; +import type { RequestCallbackOptions } from "./rpcServer/types"; import type { Payload } from "./types"; export default class CommandRunner { @@ -59,14 +60,15 @@ export default class CommandRunner { return this.rpc.executeRequest(); } - private async executeRequest({ commandId, args }: Payload) { + private async executeRequest( + { commandId, args }: Payload, + options: RequestCallbackOptions + ) { if (!vscode.window.state.focused) { if (this.backgroundWindowProtection) { throw new Error("This editor is not active"); } else { - // TODO: How should we handle this? - // warnings.push("This editor is not active"); - console.warn("This editor is not active"); + options.warn("This editor is not active"); } } diff --git a/src/rpcServer/RpcServer.ts b/src/rpcServer/RpcServer.ts index 1f67030..b72bccb 100644 --- a/src/rpcServer/RpcServer.ts +++ b/src/rpcServer/RpcServer.ts @@ -1,16 +1,18 @@ import * as fs from "fs/promises"; import * as path from "path"; -import { initializeCommunicationDir } from "./initializeCommunicationDir"; import { readRequest, writeResponse } from "./io"; -import type { RequestLatest } from "./types"; +import type { RequestCallbackOptions, RequestLatest } from "./types"; import { upgradeRequest } from "./upgradeRequest"; export class RpcServer { private requestPath: string; private responsePath: string; - private callback: (payload: T) => unknown; + private callback: (payload: T, options: RequestCallbackOptions) => unknown; - constructor(private dirPath: string, callback: (payload: T) => unknown) { + constructor( + private dirPath: string, + callback: (payload: T, options: RequestCallbackOptions) => unknown + ) { this.requestPath = path.join(this.dirPath, "request.json"); this.responsePath = path.join(this.dirPath, "response.json"); this.callback = callback; @@ -31,11 +33,17 @@ export class RpcServer { const { uuid, returnCommandOutput, waitForFinish, payload } = request; - // TODO: Do we need this? const warnings: string[] = []; + const options: RequestCallbackOptions = { + warn: (text) => warnings.push(text), + }; + try { - const commandPromise = Promise.resolve(this.callback(payload as T)); + // Wrap in promise resolve to handle both sync and async functions + const commandPromise = Promise.resolve( + this.callback(payload as T, options) + ); let commandReturnValue = null; diff --git a/src/rpcServer/types.ts b/src/rpcServer/types.ts index 40ef8cc..5c2b8b5 100644 --- a/src/rpcServer/types.ts +++ b/src/rpcServer/types.ts @@ -71,3 +71,7 @@ export interface Response { */ warnings: string[]; } + +export interface RequestCallbackOptions { + warn(text: string): void; +} From 32ed82ba838bc8549f6a96259d5936cfaa873a33 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Mon, 13 May 2024 14:12:36 +0200 Subject: [PATCH 09/14] Reorder --- src/rpcServer/types.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/rpcServer/types.ts b/src/rpcServer/types.ts index 5c2b8b5..71a0cda 100644 --- a/src/rpcServer/types.ts +++ b/src/rpcServer/types.ts @@ -62,14 +62,14 @@ export interface Response { returnValue?: unknown; /** - * Any error encountered or null if successful + * A list of warnings issued when running the command */ - error: string | null; + warnings: string[]; /** - * A list of warnings issued when running the command + * Any error encountered or null if successful */ - warnings: string[]; + error: string | null; } export interface RequestCallbackOptions { From ff6cb50ac4703868dc8832b5d189d95499a8c99b Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Mon, 20 May 2024 17:37:35 +0200 Subject: [PATCH 10/14] Refactored file system --- src/commandRunner.ts | 19 +-- src/extension.ts | 13 +- src/paths.ts | 10 +- src/rpcServer/FileSystemNode.ts | 120 ++++++++++++++++++ .../{RpcServer.ts => RpcServerFs.ts} | 47 ++++--- src/rpcServer/index.ts | 5 +- src/rpcServer/initializeCommunicationDir.ts | 25 ---- src/rpcServer/io.ts | 44 ------- src/rpcServer/types.ts | 38 ++++++ src/signal.ts | 33 ----- src/types.ts | 2 +- 11 files changed, 200 insertions(+), 156 deletions(-) create mode 100644 src/rpcServer/FileSystemNode.ts rename src/rpcServer/{RpcServer.ts => RpcServerFs.ts} (51%) delete mode 100644 src/rpcServer/initializeCommunicationDir.ts delete mode 100644 src/rpcServer/io.ts delete mode 100644 src/signal.ts diff --git a/src/commandRunner.ts b/src/commandRunner.ts index 4be5dbc..5253599 100644 --- a/src/commandRunner.ts +++ b/src/commandRunner.ts @@ -2,26 +2,21 @@ import { Minimatch } from "minimatch"; import * as vscode from "vscode"; import { getCommunicationDirPath } from "./paths"; import { any } from "./regex"; -import { RpcServer } from "./rpcServer"; -import type { RequestCallbackOptions } from "./rpcServer/types"; -import type { Payload } from "./types"; + +import type { RequestCallbackOptions, RpcServer } from "./rpcServer/types"; +import type { VscodeCommandPayload } from "./types"; +import { RpcServerFs } from "./rpcServer"; export default class CommandRunner { private allowRegex!: RegExp; private denyRegex!: RegExp | null; private backgroundWindowProtection!: boolean; - private rpc: RpcServer; - constructor() { + constructor(private rpc: RpcServer) { this.reloadConfiguration = this.reloadConfiguration.bind(this); this.runCommand = this.runCommand.bind(this); this.executeRequest = this.executeRequest.bind(this); - this.rpc = new RpcServer( - getCommunicationDirPath(), - this.executeRequest - ); - this.reloadConfiguration(); vscode.workspace.onDidChangeConfiguration(this.reloadConfiguration); } @@ -57,11 +52,11 @@ export default class CommandRunner { * types. */ runCommand(): Promise { - return this.rpc.executeRequest(); + return this.rpc.executeRequest(this.executeRequest); } private async executeRequest( - { commandId, args }: Payload, + { commandId, args }: VscodeCommandPayload, options: RequestCallbackOptions ) { if (!vscode.window.state.focused) { diff --git a/src/extension.ts b/src/extension.ts index bc91e73..868b89e 100755 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,15 +1,18 @@ import * as vscode from "vscode"; import CommandRunner from "./commandRunner"; import { getCommunicationDirPath } from "./paths"; -import { initializeCommunicationDir } from "./rpcServer"; -import { getInboundSignal } from "./signal"; +import { FileSystemNode, RpcServerFs } from "./rpcServer"; +import type { VscodeCommandPayload } from "./types"; import { FocusedElementType } from "./types"; export async function activate(context: vscode.ExtensionContext) { - initializeCommunicationDir(getCommunicationDirPath()); - const commandRunner = new CommandRunner(); + const fileSystem = new FileSystemNode(getCommunicationDirPath()); + const rpc = new RpcServerFs(fileSystem); + const commandRunner = new CommandRunner(rpc); let focusedElementType: FocusedElementType | undefined; + await fileSystem.initialize(); + context.subscriptions.push( vscode.commands.registerCommand( "command-server.runCommand", @@ -40,7 +43,7 @@ export async function activate(context: vscode.ExtensionContext) { * This signal is emitted by the voice engine to indicate that a phrase has * just begun execution. */ - prePhrase: getInboundSignal("prePhrase"), + prePhrase: rpc.getInboundSignal("prePhrase"), }, }; } diff --git a/src/paths.ts b/src/paths.ts index 18058b8..d9f6f3e 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -1,13 +1,5 @@ -import { join } from "path"; import * as rpcServer from "./rpcServer"; -const comDir = rpcServer.getCommunicationDirPath("vscode-command-server"); -const signalDir = join(comDir, "signals"); - export function getCommunicationDirPath(): string { - return comDir; -} - -export function getSignalDirPath(): string { - return signalDir; + return rpcServer.getCommunicationDirPath("vscode-command-server"); } diff --git a/src/rpcServer/FileSystemNode.ts b/src/rpcServer/FileSystemNode.ts new file mode 100644 index 0000000..7f07177 --- /dev/null +++ b/src/rpcServer/FileSystemNode.ts @@ -0,0 +1,120 @@ +import { S_IWOTH } from "constants"; +import * as fs from "fs"; +import * as fsPromises from "fs/promises"; +import * as os from "os"; +import * as path from "path"; +import type { FileSystem, Request, Response } from "./types"; + +// The amount of time that client is expected to wait for the server to perform a +// command, in milliseconds. +export const COMMAND_TIMEOUT_MS = 3000; + +class InboundSignal { + constructor(private path: string) {} + + /** + * Gets the current version of the signal. This version string changes every + * time the signal is emitted, and can be used to detect whether signal has + * been emitted between two timepoints. + * @returns The current signal version or null if the signal file could not be + * found + */ + async getVersion() { + try { + return (await fsPromises.stat(this.path)).mtimeMs.toString(); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + throw err; + } + + return null; + } + } +} + +export class FileSystemNode implements FileSystem { + private requestPath: string; + private responsePath: string; + private signalsDirPath: string; + private responseFile: fsPromises.FileHandle | null; + + constructor(private dirPath: string) { + this.requestPath = path.join(this.dirPath, "request.json"); + this.responsePath = path.join(this.dirPath, "response.json"); + this.signalsDirPath = path.join(this.dirPath, "signals"); + this.responseFile = null; + } + + async initialize(): Promise { + console.debug(`Creating communication dir ${this.dirPath}`); + + fs.mkdirSync(this.dirPath, { recursive: true, mode: 0o770 }); + + const stats = fs.lstatSync(this.dirPath); + + const info = os.userInfo(); + + if ( + !stats.isDirectory() || + stats.isSymbolicLink() || + stats.mode & S_IWOTH || + // On Windows, uid < 0, so we don't worry about it for simplicity + (info.uid >= 0 && stats.uid !== info.uid) + ) { + throw new Error( + `Refusing to proceed because of invalid communication dir ${this.dirPath}` + ); + } + } + + async prepareResponse(): Promise { + if (this.responseFile) { + throw new Error("response is already locked"); + } + this.responseFile = await fsPromises.open(this.responsePath, "wx"); + } + + async closeResponse(): Promise { + if (!this.responseFile) { + throw new Error("response is not locked"); + } + await this.responseFile.close(); + this.responseFile = null; + } + + /** + * Reads the JSON-encoded request from the request file + * @returns A promise that resolves to a Response object + */ + async readRequest(): Promise { + const stats = await fsPromises.stat(this.requestPath); + const request = JSON.parse( + await fsPromises.readFile(this.requestPath, "utf-8") + ); + + if (Math.abs(stats.mtimeMs - Date.now()) > COMMAND_TIMEOUT_MS) { + throw new Error( + "Request file is older than timeout; refusing to execute command" + ); + } + + return request; + } + + /** + * Writes the response to the response file as JSON. + * @param file The file to write to + * @param response The response object to JSON-encode and write to disk + */ + async writeResponse(response: Response) { + if (!this.responseFile) { + throw new Error("response is not locked"); + } + await this.responseFile.write(`${JSON.stringify(response)}\n`); + } + + getInboundSignal(name: string) { + const signalPath = path.join(this.signalsDirPath, name); + return new InboundSignal(signalPath); + } +} diff --git a/src/rpcServer/RpcServer.ts b/src/rpcServer/RpcServerFs.ts similarity index 51% rename from src/rpcServer/RpcServer.ts rename to src/rpcServer/RpcServerFs.ts index b72bccb..3cfddd9 100644 --- a/src/rpcServer/RpcServer.ts +++ b/src/rpcServer/RpcServerFs.ts @@ -1,33 +1,26 @@ -import * as fs from "fs/promises"; -import * as path from "path"; -import { readRequest, writeResponse } from "./io"; -import type { RequestCallbackOptions, RequestLatest } from "./types"; +import type { + Callback, + FileSystem, + RequestCallbackOptions, + RequestLatest, + RpcServer, + SignalReader, +} from "./types"; import { upgradeRequest } from "./upgradeRequest"; -export class RpcServer { - private requestPath: string; - private responsePath: string; - private callback: (payload: T, options: RequestCallbackOptions) => unknown; +export class RpcServerFs implements RpcServer { + constructor(private fileSystem: FileSystem) {} - constructor( - private dirPath: string, - callback: (payload: T, options: RequestCallbackOptions) => unknown - ) { - this.requestPath = path.join(this.dirPath, "request.json"); - this.responsePath = path.join(this.dirPath, "response.json"); - this.callback = callback; - } - - async executeRequest() { - const responseFile = await fs.open(this.requestPath, "wx"); + async executeRequest(callback: Callback) { + await this.fileSystem.prepareResponse(); let request: RequestLatest; try { - const requestInput = await readRequest(this.requestPath); + const requestInput = await this.fileSystem.readRequest(); request = upgradeRequest(requestInput); } catch (err) { - await responseFile.close(); + await this.fileSystem.closeResponse(); throw err; } @@ -42,7 +35,7 @@ export class RpcServer { try { // Wrap in promise resolve to handle both sync and async functions const commandPromise = Promise.resolve( - this.callback(payload as T, options) + callback(payload as T, options) ); let commandReturnValue = null; @@ -53,20 +46,24 @@ export class RpcServer { await commandPromise; } - await writeResponse(responseFile, { + await this.fileSystem.writeResponse({ uuid, warnings, error: null, returnValue: commandReturnValue, }); } catch (err) { - await writeResponse(responseFile, { + await this.fileSystem.writeResponse({ uuid, warnings, error: (err as Error).message, }); } - await responseFile.close(); + await this.fileSystem.closeResponse(); + } + + getInboundSignal(name: string): SignalReader { + return this.fileSystem.getInboundSignal(name); } } diff --git a/src/rpcServer/index.ts b/src/rpcServer/index.ts index 3bed043..0764d43 100644 --- a/src/rpcServer/index.ts +++ b/src/rpcServer/index.ts @@ -1,3 +1,4 @@ -export * from "./RpcServer"; +export * from "./RpcServerFs"; +export * from "./types"; export * from "./getCommunicationDirPath"; -export * from "./initializeCommunicationDir"; +export * from "./FileSystemNode"; diff --git a/src/rpcServer/initializeCommunicationDir.ts b/src/rpcServer/initializeCommunicationDir.ts deleted file mode 100644 index 366350e..0000000 --- a/src/rpcServer/initializeCommunicationDir.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { S_IWOTH } from "constants"; -import { lstatSync, mkdirSync } from "fs"; -import { userInfo } from "os"; - -export function initializeCommunicationDir(dirPath: string) { - console.debug(`Creating communication dir ${dirPath}`); - - mkdirSync(dirPath, { recursive: true, mode: 0o770 }); - - const stats = lstatSync(dirPath); - - const info = userInfo(); - - if ( - !stats.isDirectory() || - stats.isSymbolicLink() || - stats.mode & S_IWOTH || - // On Windows, uid < 0, so we don't worry about it for simplicity - (info.uid >= 0 && stats.uid !== info.uid) - ) { - throw new Error( - `Refusing to proceed because of invalid communication dir ${dirPath}` - ); - } -} diff --git a/src/rpcServer/io.ts b/src/rpcServer/io.ts deleted file mode 100644 index 37d8655..0000000 --- a/src/rpcServer/io.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { FileHandle, readFile, stat } from "fs/promises"; -import { Request, Response } from "./types"; - -// The amount of time that client is expected to wait for the server to perform a -// command, in milliseconds. -export const COMMAND_TIMEOUT_MS = 3000; - -/** - * Reads the JSON-encoded request from the request file, unlinking the file - * after reading. - * @returns A promise that resolves to a Response object - */ -export async function readRequest(requestPath: string): Promise { - const stats = await stat(requestPath); - const content = await readFile(requestPath, "utf-8"); - const request = JSON.parse(content); - - if (Math.abs(stats.mtimeMs - Date.now()) > COMMAND_TIMEOUT_MS) { - throw new Error( - "Request file is older than timeout; refusing to execute command" - ); - } - - return request; -} - -/** - * Writes the response to the response file as JSON. - * @param file The file to write to - * @param response The response object to JSON-encode and write to disk - */ -export async function writeResponse(file: FileHandle, response: Response) { - await writeJSON(file, response); -} - -/** - * Writes stringified JSON. - * Appends newline so that other side knows when it is done - * @param path Output path - * @param body Body to stringify and write - */ -async function writeJSON(file: FileHandle, body: any) { - await file.write(`${JSON.stringify(body)}\n`); -} diff --git a/src/rpcServer/types.ts b/src/rpcServer/types.ts index 71a0cda..dba70d7 100644 --- a/src/rpcServer/types.ts +++ b/src/rpcServer/types.ts @@ -75,3 +75,41 @@ export interface Response { export interface RequestCallbackOptions { warn(text: string): void; } + +export type Callback = ( + payload: T, + options: RequestCallbackOptions +) => unknown; + +export interface RpcServer { + executeRequest: (callback: Callback) => Promise; + getInboundSignal: (name: string) => SignalReader; +} + +export interface SignalReader { + /** + * Gets the current version of the signal. This version string changes every + * time the signal is emitted, and can be used to detect whether signal has + * been emitted between two timepoints. + * @returns The current signal version or null if the signal file could not be + * found + */ + getVersion: () => Promise; +} + +export interface FileSystem { + initialize: () => Promise; + // Prepares to send a response to readRequest, preventing any other process + // from doing so until closeResponse is called. Throws an error if called + // twice before closeResponse. + prepareResponse: () => Promise; + // Closes a prepared response, allowing other processes to respond to + // readRequest. Throws an error if the prepareResponse has not been called. + closeResponse: () => Promise; + // Returns a request from Talon command client. + readRequest: () => Promise; + // Writes a response. Throws an error if prepareResponse has not been called. + writeResponse: (response: Response) => Promise; + // Returns a SignalReader. + getInboundSignal: (name: string) => SignalReader; +} diff --git a/src/signal.ts b/src/signal.ts deleted file mode 100644 index 02dd679..0000000 --- a/src/signal.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { stat } from "fs/promises"; -import { join } from "path"; -import { getSignalDirPath } from "./paths"; - -class InboundSignal { - constructor(private path: string) {} - - /** - * Gets the current version of the signal. This version string changes every - * time the signal is emitted, and can be used to detect whether signal has - * been emitted between two timepoints. - * @returns The current signal version or null if the signal file could not be - * found - */ - async getVersion() { - try { - return (await stat(this.path)).mtimeMs.toString(); - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== "ENOENT") { - throw err; - } - - return null; - } - } -} - -export function getInboundSignal(name: string) { - const signalDir = getSignalDirPath(); - const path = join(signalDir, name); - - return new InboundSignal(path); -} diff --git a/src/types.ts b/src/types.ts index f84b070..444dae2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,7 +3,7 @@ */ export type FocusedElementType = "textEditor" | "terminal" | "other"; -export interface Payload { +export interface VscodeCommandPayload { /** * The id of the command to run */ From 4bc5c1b715d08eb0b31b38ee0581d959b93b61ce Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Mon, 20 May 2024 17:49:46 +0200 Subject: [PATCH 11/14] rename --- src/rpcServer/RpcServerFs.ts | 4 ++-- src/rpcServer/types.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/rpcServer/RpcServerFs.ts b/src/rpcServer/RpcServerFs.ts index 3cfddd9..7fe0f19 100644 --- a/src/rpcServer/RpcServerFs.ts +++ b/src/rpcServer/RpcServerFs.ts @@ -1,5 +1,5 @@ import type { - Callback, + RequestCallback, FileSystem, RequestCallbackOptions, RequestLatest, @@ -11,7 +11,7 @@ import { upgradeRequest } from "./upgradeRequest"; export class RpcServerFs implements RpcServer { constructor(private fileSystem: FileSystem) {} - async executeRequest(callback: Callback) { + async executeRequest(callback: RequestCallback) { await this.fileSystem.prepareResponse(); let request: RequestLatest; diff --git a/src/rpcServer/types.ts b/src/rpcServer/types.ts index dba70d7..05f355b 100644 --- a/src/rpcServer/types.ts +++ b/src/rpcServer/types.ts @@ -76,13 +76,13 @@ export interface RequestCallbackOptions { warn(text: string): void; } -export type Callback = ( +export type RequestCallback = ( payload: T, options: RequestCallbackOptions ) => unknown; export interface RpcServer { - executeRequest: (callback: Callback) => Promise; + executeRequest: (callback: RequestCallback) => Promise; getInboundSignal: (name: string) => SignalReader; } From 5e3c6b39cb04a48f567f9f19732b861b1bb992a8 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Mon, 20 May 2024 18:05:59 +0200 Subject: [PATCH 12/14] Make private --- src/commandRunner.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commandRunner.ts b/src/commandRunner.ts index 5253599..7e13470 100644 --- a/src/commandRunner.ts +++ b/src/commandRunner.ts @@ -21,7 +21,7 @@ export default class CommandRunner { vscode.workspace.onDidChangeConfiguration(this.reloadConfiguration); } - reloadConfiguration() { + private reloadConfiguration() { const allowList = vscode.workspace .getConfiguration("command-server") .get("allowList")!; From 49179dff2335fb5849789a7c18291f83432354b9 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Sat, 25 May 2024 09:54:36 +0200 Subject: [PATCH 13/14] Update src/commandRunner.ts Co-authored-by: David Vo --- src/commandRunner.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/commandRunner.ts b/src/commandRunner.ts index 7e13470..542995e 100644 --- a/src/commandRunner.ts +++ b/src/commandRunner.ts @@ -5,7 +5,6 @@ import { any } from "./regex"; import type { RequestCallbackOptions, RpcServer } from "./rpcServer/types"; import type { VscodeCommandPayload } from "./types"; -import { RpcServerFs } from "./rpcServer"; export default class CommandRunner { private allowRegex!: RegExp; From 4eaec310a5ed0b8b06d736c846a91db7454edcd1 Mon Sep 17 00:00:00 2001 From: Andreas Arvidsson Date: Sat, 25 May 2024 09:54:57 +0200 Subject: [PATCH 14/14] Clean up --- src/commandRunner.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/commandRunner.ts b/src/commandRunner.ts index 542995e..298ad7d 100644 --- a/src/commandRunner.ts +++ b/src/commandRunner.ts @@ -1,8 +1,6 @@ import { Minimatch } from "minimatch"; import * as vscode from "vscode"; -import { getCommunicationDirPath } from "./paths"; import { any } from "./regex"; - import type { RequestCallbackOptions, RpcServer } from "./rpcServer/types"; import type { VscodeCommandPayload } from "./types";