Skip to content

Create Io interface and refactor fs-specific code into NativeIo #19

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jan 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 11 additions & 13 deletions src/commandRunner.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
import { open } from "fs/promises";
import { Minimatch } from "minimatch";
import * as vscode from "vscode";

import { readRequest, writeResponse } from "./io";
import { getResponsePath } from "./paths";
import { any } from "./regex";
import { Request } from "./types";
import { Io } from "./io";

export default class CommandRunner {
allowRegex!: RegExp;
denyRegex!: RegExp | null;
backgroundWindowProtection!: boolean;
private allowRegex!: RegExp;
private denyRegex!: RegExp | null;
private backgroundWindowProtection!: boolean;

constructor() {
constructor(private io: Io) {
this.reloadConfiguration = this.reloadConfiguration.bind(this);
this.runCommand = this.runCommand.bind(this);

Expand Down Expand Up @@ -51,14 +49,14 @@ export default class CommandRunner {
* types.
*/
async runCommand() {
const responseFile = await open(getResponsePath(), "wx");
await this.io.prepareResponse();

let request: Request;

try {
request = await readRequest();
request = await this.io.readRequest();
} catch (err) {
await responseFile.close();
await this.io.closeResponse();
throw err;
}

Expand Down Expand Up @@ -94,20 +92,20 @@ export default class CommandRunner {
await commandPromise;
}

await writeResponse(responseFile, {
await this.io.writeResponse({
error: null,
uuid,
returnValue: commandReturnValue,
warnings,
});
} catch (err) {
await writeResponse(responseFile, {
await this.io.writeResponse({
error: (err as Error).message,
uuid,
warnings,
});
}

await responseFile.close();
await this.io.closeResponse();
}
}
12 changes: 6 additions & 6 deletions src/extension.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import * as vscode from "vscode";

import { NativeIo } from "./nativeIo";
import CommandRunner from "./commandRunner";
import { initializeCommunicationDir } from "./initializeCommunicationDir";
import { getInboundSignal } from "./signal";
import { FocusedElementType } from "./types";

export function activate(context: vscode.ExtensionContext) {
initializeCommunicationDir();
export async function activate(context: vscode.ExtensionContext) {
const io = new NativeIo();
await io.initialize();

const commandRunner = new CommandRunner();
const commandRunner = new CommandRunner(io);
let focusedElementType: FocusedElementType | undefined;

context.subscriptions.push(
Expand Down Expand Up @@ -40,7 +40,7 @@ export 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: io.getInboundSignal("prePhrase"),
},
};
}
Expand Down
11 changes: 0 additions & 11 deletions src/fileUtils.ts

This file was deleted.

29 changes: 0 additions & 29 deletions src/initializeCommunicationDir.ts

This file was deleted.

55 changes: 24 additions & 31 deletions src/io.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,29 @@
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<Request> {
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;
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<string | null>;
}

/**
* 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);
export interface Io {
initialize: () => Promise<void>;
// 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<void>;
// Closes a prepared response, allowing other processes to respond to
// readRequest. Throws an error if the prepareResponse has not been called.
closeResponse: () => Promise<void>;
// Returns a request from Talon command client.
readRequest: () => Promise<Request>;
// Writes a response. Throws an error if prepareResponse has not been called.
writeResponse: (response: Response) => Promise<void>;
// Returns a SignalReader.
getInboundSignal: (name: string) => SignalReader;
}
123 changes: 123 additions & 0 deletions src/nativeIo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { mkdirSync, lstatSync } from "fs";
import { join } from "path";
import { S_IWOTH } from "constants";
import {
getCommunicationDirPath,
getRequestPath,
getResponsePath,
getSignalDirPath,
} from "./paths";
import { userInfo } from "os";
import { Io } from "./io";
import { FileHandle, open, readFile, stat } from "fs/promises";
import { VSCODE_COMMAND_TIMEOUT_MS } from "./constants";
import { Request, Response } from "./types";

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 class NativeIo implements Io {
private responseFile: FileHandle | null;

constructor() {
this.responseFile = null;
}

async initialize(): Promise<void> {
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}`
);
}
}

async prepareResponse(): Promise<void> {
if (this.responseFile) {
throw new Error("response is already locked");
}
this.responseFile = await open(getResponsePath(), "wx");
}

async closeResponse(): Promise<void> {
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, unlinking the file
* after reading.
* @returns A promise that resolves to a Response object
*/
async readRequest(): Promise<Request> {
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
*/
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 signalDir = getSignalDirPath();
const path = join(signalDir, name);
return new InboundSignal(path);
}
}
33 changes: 0 additions & 33 deletions src/signal.ts

This file was deleted.