feat: snip a region into chat, and make links clickable #14
@@ -39,6 +39,10 @@ Vicinae has no screenshot API, so the extension shells out. It auto-detects the
|
||||
|
||||
If none is found, the chat says so and names what to install. Nothing to configure: the detection happens per platform at capture time.
|
||||
|
||||
**On macOS, Vicinae itself needs Screen Recording permission.** macOS attributes a capture request to the app responsible for launching the process that made it, which is Vicinae rather than the extension, so the grant goes on Vicinae: **System Settings, Privacy & Security, Screen & System Audio Recording**, then quit and reopen Vicinae (a TCC change does not apply to a running app).
|
||||
|
||||
Without it, `screencapture` prints `could not create image from rect` and writes nothing. That is awkward, because a cancelled selection also writes nothing, and `screencapture -i` has been reported to exit `0` when the permission is missing. So the file alone cannot tell the two apart. `capture.ts` therefore reads stderr as well: that specific message becomes a permission error naming the setting to change, and only silence with no image is treated as a cancel.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
@@ -195,7 +199,9 @@ A few decisions worth knowing about before you change things:
|
||||
|
||||
**Sessions are capped twice over**, once per turn and once per session, because `LocalStorage` has no documented ceiling.
|
||||
|
||||
**Screen capture is owned, not borrowed.** There is no screenshot call anywhere in `@vicinae/api`, and `Clipboard` moves text, html, urls and a file path and nothing binary, so a bitmap can never be read back out of the clipboard either. The only way to get a region of the screen is to shell out, which is fair game because extensions run under Node. The useful consequence is that the extension picks the output path and reads the file it wrote, rather than guessing what some other application left on the clipboard. Cancel detection leans on the file for the same reason: every tool reports an abandoned selection differently, and at least one exits `0` on cancel, so whether an image actually landed on disk is the only signal they agree on. Snips go in the extension's support directory rather than a temp directory, because a stored conversation keeps the path and a path that survives a reboot is the difference between an old chat showing its screenshot and pointing at nothing.
|
||||
**Screen capture is owned, not borrowed.** There is no screenshot call anywhere in `@vicinae/api`, and `Clipboard` moves text, html, urls and a file path and nothing binary, so a bitmap can never be read back out of the clipboard either. The only way to get a region of the screen is to shell out, which is fair game because extensions run under Node. The useful consequence is that the extension picks the output path and reads the file it wrote, rather than guessing what some other application left on the clipboard.
|
||||
|
||||
Deciding whether a capture *worked* takes two signals, not one. The image landing on disk is what makes it a success, because the backends disagree about exit codes and at least one reports success on a cancelled selection. But the file cannot distinguish a cancel from a refused permission, since both leave nothing behind and macOS can exit `0` on the refusal, so the refusal is recognised by its stderr message instead. Silence with no image is the user pressing Escape; a complaint with no image is a failure the user is told about. Snips go in the extension's support directory rather than a temp directory, because a stored conversation keeps the path and a path that survives a reboot is the difference between an old chat showing its screenshot and pointing at nothing.
|
||||
|
||||
**Capture runs in a `no-view` command, because the chat view owns a window.** This is the part that is easy to get wrong. `activateEntrypoint` calls `showWindow()` when it activates a command from a closed launcher, so the window is already on screen before any extension code runs, and a region selector started from a view is dragging a rectangle around a panel parked in the middle of the screen. So `snip` is declared `no-view`, calls `closeMainWindow()` as its first statement, captures with nothing in the way, and only then brings the window back by launching `chat` with the file. That command is also the single capture path: the chat's own action launches `snip` rather than capturing inline, so there is one implementation and one place that deals with the window.
|
||||
|
||||
|
||||
+132
-34
@@ -4,6 +4,8 @@ import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CAPTURE_BACKENDS,
|
||||
CapturePermissionError,
|
||||
CaptureToolError,
|
||||
NoCaptureToolError,
|
||||
captureRegion,
|
||||
selectBackend,
|
||||
@@ -23,6 +25,11 @@ function backendNamed(name: string): CaptureBackend {
|
||||
return backend;
|
||||
}
|
||||
|
||||
/** A tool that ran, said nothing, and produced no image. */
|
||||
function silent(): { stdout: Buffer; stderr: string } {
|
||||
return { stdout: Buffer.alloc(0), stderr: "" };
|
||||
}
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function tempDir(): Promise<string> {
|
||||
@@ -89,7 +96,7 @@ describe("backend commands", () => {
|
||||
const calls: Array<{ tool: string; args: string[] }> = [];
|
||||
const run = vi.fn(async (tool: string, args: string[]) => {
|
||||
calls.push({ tool, args });
|
||||
return tool === "slurp" ? Buffer.from("120,340 800x600\n") : Buffer.alloc(0);
|
||||
return tool === "slurp" ? { stdout: Buffer.from("120,340 800x600\n"), stderr: "" } : silent();
|
||||
});
|
||||
|
||||
await backendNamed("grim + slurp").capture({ output: "/tmp/snip.png", run });
|
||||
@@ -101,7 +108,7 @@ describe("backend commands", () => {
|
||||
});
|
||||
|
||||
it("does not shell out to grim when the selection was cancelled", async () => {
|
||||
const run = vi.fn(async () => Buffer.from(""));
|
||||
const run = vi.fn(async () => silent());
|
||||
|
||||
await backendNamed("grim + slurp").capture({ output: "/tmp/snip.png", run });
|
||||
|
||||
@@ -120,7 +127,7 @@ describe("backend commands", () => {
|
||||
const output = join(dir, "snip.png");
|
||||
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
||||
|
||||
await backendNamed("flameshot").capture({ output, run: async () => png });
|
||||
await backendNamed("flameshot").capture({ output, run: async () => ({ stdout: png, stderr: "" }) });
|
||||
|
||||
expect(await readFile(output)).toEqual(png);
|
||||
});
|
||||
@@ -129,13 +136,13 @@ describe("backend commands", () => {
|
||||
const dir = await tempDir();
|
||||
const output = join(dir, "snip.png");
|
||||
|
||||
await backendNamed("flameshot").capture({ output, run: async () => Buffer.alloc(0) });
|
||||
await backendNamed("flameshot").capture({ output, run: async () => silent() });
|
||||
|
||||
await expect(readFile(output)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("asks screencapture for an interactive region and no sound", async () => {
|
||||
const run = vi.fn(async () => Buffer.alloc(0));
|
||||
const run = vi.fn(async () => silent());
|
||||
|
||||
await backendNamed("screencapture").capture({ output: "/tmp/snip.png", run });
|
||||
|
||||
@@ -144,6 +151,14 @@ describe("backend commands", () => {
|
||||
});
|
||||
|
||||
describe("captureRegion", () => {
|
||||
/** A `run` that writes a real file, which is what the success path checks for. */
|
||||
function writingRun(bytes = Buffer.from([0x89, 0x50])) {
|
||||
return async (_tool: string, args: string[]) => {
|
||||
await writeFile(args[args.length - 1], bytes);
|
||||
return silent();
|
||||
};
|
||||
}
|
||||
|
||||
it("writes into the directory it was given and reports the path", async () => {
|
||||
const dir = await tempDir();
|
||||
const output = join(dir, "snip-20261004-145926.png");
|
||||
@@ -153,23 +168,36 @@ describe("captureRegion", () => {
|
||||
platform: "darwin",
|
||||
now: new Date(2026, 9, 4, 14, 59, 26),
|
||||
hasCommand: pathWith("screencapture"),
|
||||
run: async (_tool, args) => {
|
||||
await writeFile(args[args.length - 1], Buffer.from([0x89, 0x50]));
|
||||
return Buffer.alloc(0);
|
||||
},
|
||||
run: writingRun(Buffer.from([0x89, 0x50])),
|
||||
});
|
||||
|
||||
expect(result).toEqual({ path: output, bytes: 2, backend: "screencapture" });
|
||||
});
|
||||
|
||||
it("returns null when the user backed out and no image was written", async () => {
|
||||
it("creates the output directory on first use", async () => {
|
||||
const dir = await tempDir();
|
||||
const nested = join(dir, "snips");
|
||||
const now = new Date(2026, 9, 4, 14, 59, 26);
|
||||
|
||||
await captureRegion({
|
||||
outputDir: nested,
|
||||
platform: "darwin",
|
||||
now,
|
||||
hasCommand: pathWith("screencapture"),
|
||||
run: writingRun(Buffer.from([0x89])),
|
||||
});
|
||||
|
||||
expect((await readFile(join(nested, snipFileName(now)))).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns null when the user backed out and said nothing", async () => {
|
||||
const dir = await tempDir();
|
||||
|
||||
const result = await captureRegion({
|
||||
outputDir: dir,
|
||||
platform: "darwin",
|
||||
hasCommand: pathWith("screencapture"),
|
||||
run: async () => Buffer.alloc(0),
|
||||
run: async () => silent(),
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
@@ -184,15 +212,104 @@ describe("captureRegion", () => {
|
||||
outputDir: dir,
|
||||
platform: "darwin",
|
||||
hasCommand: pathWith("screencapture"),
|
||||
run: async (_tool, args) => {
|
||||
await writeFile(args[args.length - 1], Buffer.alloc(0));
|
||||
return Buffer.alloc(0);
|
||||
},
|
||||
run: writingRun(Buffer.alloc(0)),
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("still succeeds when the tool also complained on stderr", async () => {
|
||||
// Some tools warn on stderr and write the image anyway. The file is the
|
||||
// source of truth, so a warning must not turn a good capture into an error.
|
||||
const dir = await tempDir();
|
||||
|
||||
const result = await captureRegion({
|
||||
outputDir: dir,
|
||||
platform: "darwin",
|
||||
hasCommand: pathWith("screencapture"),
|
||||
run: async (_tool, args) => {
|
||||
await writeFile(args[args.length - 1], Buffer.from([0x89]));
|
||||
return { stdout: Buffer.alloc(0), stderr: "warning: something\n" };
|
||||
},
|
||||
});
|
||||
|
||||
expect(result?.bytes).toBe(1);
|
||||
});
|
||||
|
||||
describe("macOS Screen Recording permission", () => {
|
||||
// The message is the only difference between a refused permission and a
|
||||
// cancelled selection: both leave no file behind, and screencapture has been
|
||||
// reported to exit 0 when the permission is missing.
|
||||
const denial = "could not create image from rect\n";
|
||||
|
||||
it("reports a permission problem instead of a cancel when the tool exits non-zero", async () => {
|
||||
const dir = await tempDir();
|
||||
|
||||
await expect(
|
||||
captureRegion({
|
||||
outputDir: dir,
|
||||
platform: "darwin",
|
||||
hasCommand: pathWith("screencapture"),
|
||||
run: async () => {
|
||||
throw new CaptureToolError("screencapture", denial);
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(CapturePermissionError);
|
||||
});
|
||||
|
||||
it("reports a permission problem even when the tool exits 0", async () => {
|
||||
// The documented exit-0 shape. Without reading stderr this is
|
||||
// indistinguishable from Escape, which is how a silently useless feature
|
||||
// gets shipped.
|
||||
const dir = await tempDir();
|
||||
|
||||
await expect(
|
||||
captureRegion({
|
||||
outputDir: dir,
|
||||
platform: "darwin",
|
||||
hasCommand: pathWith("screencapture"),
|
||||
run: async () => ({ stdout: Buffer.alloc(0), stderr: denial }),
|
||||
}),
|
||||
).rejects.toThrow(CapturePermissionError);
|
||||
});
|
||||
|
||||
it("names Vicinae rather than the extension, because that is who holds the grant", async () => {
|
||||
const dir = await tempDir();
|
||||
const attempt = () =>
|
||||
captureRegion({
|
||||
outputDir: dir,
|
||||
platform: "darwin",
|
||||
hasCommand: pathWith("screencapture"),
|
||||
run: async () => {
|
||||
throw new CaptureToolError("screencapture", denial);
|
||||
},
|
||||
});
|
||||
|
||||
await expect(attempt()).rejects.toMatchObject({
|
||||
name: "CapturePermissionError",
|
||||
hint: expect.stringContaining("Vicinae"),
|
||||
});
|
||||
await expect(attempt()).rejects.toMatchObject({
|
||||
hint: expect.stringContaining("Screen Recording"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("reports any other explanation rather than swallowing it", async () => {
|
||||
const dir = await tempDir();
|
||||
|
||||
await expect(
|
||||
captureRegion({
|
||||
outputDir: dir,
|
||||
platform: "linux",
|
||||
hasCommand: pathWith("maim"),
|
||||
run: async () => {
|
||||
throw new CaptureToolError("maim", "maim: cannot open display\n");
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(CaptureToolError);
|
||||
});
|
||||
|
||||
it("explains what to install when nothing is available", async () => {
|
||||
const dir = await tempDir();
|
||||
|
||||
@@ -201,25 +318,6 @@ describe("captureRegion", () => {
|
||||
).rejects.toThrow(NoCaptureToolError);
|
||||
});
|
||||
|
||||
it("creates the output directory on first use", async () => {
|
||||
const dir = await tempDir();
|
||||
const nested = join(dir, "snips");
|
||||
const now = new Date(2026, 9, 4, 14, 59, 26);
|
||||
|
||||
await captureRegion({
|
||||
outputDir: nested,
|
||||
platform: "darwin",
|
||||
now,
|
||||
hasCommand: pathWith("screencapture"),
|
||||
run: async (_tool, args) => {
|
||||
await writeFile(args[args.length - 1], Buffer.from([0x89]));
|
||||
return Buffer.alloc(0);
|
||||
},
|
||||
});
|
||||
|
||||
expect((await readFile(join(nested, snipFileName(now)))).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("does not invent a backend on macOS", async () => {
|
||||
const dir = await tempDir();
|
||||
|
||||
|
||||
+107
-16
@@ -28,12 +28,28 @@ const MAX_CAPTURE_BYTES = 64 * 1024 * 1024;
|
||||
/** Long enough to pick a region unhurried, short enough that a wedged tool cannot hang the view forever. */
|
||||
const CAPTURE_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
/** Runs a command and resolves with its stdout. Injected so tests never spawn anything. */
|
||||
/**
|
||||
* What macOS says when Screen Recording permission is missing.
|
||||
*
|
||||
* This one pattern is why the module reads stderr at all. The message is the
|
||||
* *only* difference between "the system refused to let us look" and "the user
|
||||
* pressed Escape": both produce no file, and `screencapture -i` has been
|
||||
* reported to exit `0` in the refusal case. Treating the refusal as a cancel
|
||||
* leaves the user with a feature that silently does nothing.
|
||||
*/
|
||||
const SCREEN_RECORDING_DENIED = /could not create image from (rect|display|window)/i;
|
||||
|
||||
export interface RunResult {
|
||||
stdout: Buffer;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
/** Runs a command. Injected so tests never spawn anything. */
|
||||
export type RunCommand = (
|
||||
tool: string,
|
||||
args: string[],
|
||||
options?: { signal?: AbortSignal },
|
||||
) => Promise<Buffer>;
|
||||
) => Promise<RunResult>;
|
||||
|
||||
export interface CaptureBackend {
|
||||
/** Shown to the user when a capture fails, so they know what to install. */
|
||||
@@ -83,6 +99,42 @@ export class NoCaptureToolError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Raised when the system refuses to hand over the screen.
|
||||
*
|
||||
* Separate from a generic tool failure because it is the one capture error the
|
||||
* user can actually fix, and because on macOS the fix is not obvious: the
|
||||
* permission belongs to the *launcher*, since TCC attributes the request to the
|
||||
* app responsible for spawning the extension runtime rather than to the
|
||||
* extension itself.
|
||||
*/
|
||||
export class CapturePermissionError extends Error {
|
||||
readonly hint: string;
|
||||
|
||||
constructor(platform: NodeJS.Platform) {
|
||||
super("The system denied screen capture.");
|
||||
this.name = "CapturePermissionError";
|
||||
this.hint =
|
||||
platform === "darwin"
|
||||
? "Give Vicinae Screen Recording permission in System Settings under Privacy & Security, then quit Vicinae and open it again. The permission belongs to Vicinae rather than to the extension, because macOS attributes the request to the app that launched the extension runtime."
|
||||
: "Give the launcher permission to record the screen, then restart it.";
|
||||
}
|
||||
}
|
||||
|
||||
/** A capture tool that ran and failed for a reason it could explain. */
|
||||
export class CaptureToolError extends Error {
|
||||
readonly tool: string;
|
||||
readonly stderr: string;
|
||||
|
||||
constructor(tool: string, stderr: string, cause?: unknown) {
|
||||
const detail = stderr.trim().split("\n")[0] ?? "";
|
||||
super(`${tool} failed.${detail ? ` ${detail}` : ""}`, cause === undefined ? undefined : { cause });
|
||||
this.name = "CaptureToolError";
|
||||
this.tool = tool;
|
||||
this.stderr = stderr;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `command -v` rather than spawning the tool itself: several of these block or
|
||||
* open a window when run bare, and `--version` is not reliably supported.
|
||||
@@ -96,7 +148,11 @@ async function commandExists(tool: string): Promise<boolean> {
|
||||
});
|
||||
}
|
||||
|
||||
function runCommand(tool: string, args: string[], options: { signal?: AbortSignal } = {}): Promise<Buffer> {
|
||||
function runCommand(
|
||||
tool: string,
|
||||
args: string[],
|
||||
options: { signal?: AbortSignal } = {},
|
||||
): Promise<RunResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
tool,
|
||||
@@ -107,9 +163,15 @@ function runCommand(tool: string, args: string[], options: { signal?: AbortSigna
|
||||
maxBuffer: MAX_CAPTURE_BYTES,
|
||||
encoding: "buffer",
|
||||
},
|
||||
(error, stdout) => {
|
||||
if (error) reject(error);
|
||||
else resolve(stdout as Buffer);
|
||||
(error, stdout, stderr) => {
|
||||
const text = stderr?.toString("utf8") ?? "";
|
||||
|
||||
if (error) {
|
||||
reject(new CaptureToolError(tool, text, error));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({ stdout: stdout as Buffer, stderr: text });
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -125,7 +187,7 @@ const grimSlurp: CaptureBackend = {
|
||||
name: "grim + slurp",
|
||||
requires: ["grim", "slurp"],
|
||||
async capture({ output, signal, run }) {
|
||||
const geometry = (await run("slurp", [], { signal })).toString("utf8").trim();
|
||||
const geometry = (await run("slurp", [], { signal })).stdout.toString("utf8").trim();
|
||||
if (!geometry) return;
|
||||
await run("grim", ["-g", geometry, output], { signal });
|
||||
},
|
||||
@@ -139,7 +201,7 @@ const flameshot: CaptureBackend = {
|
||||
name: "flameshot",
|
||||
requires: ["flameshot"],
|
||||
async capture({ output, signal, run }) {
|
||||
const png = await run("flameshot", ["gui", "--raw"], { signal });
|
||||
const png = (await run("flameshot", ["gui", "--raw"], { signal })).stdout;
|
||||
if (png.length === 0) return;
|
||||
await writeFile(output, png);
|
||||
},
|
||||
@@ -227,15 +289,15 @@ export function snipFileName(now: Date): string {
|
||||
* Captures a region and returns the file it wrote, or `null` when the user
|
||||
* backed out.
|
||||
*
|
||||
* Cancel detection leans on the file rather than the exit code on purpose:
|
||||
* every tool reports a cancelled selection differently, and at least one
|
||||
* reports success anyway. Whether an image actually landed on disk is the one
|
||||
* signal they all agree on.
|
||||
* Success is decided by the file, not by an exit code: the tools disagree about
|
||||
* what a cancelled selection looks like, and at least one reports success
|
||||
* anyway. Failure is then triaged from what the tool said on stderr, which is
|
||||
* the only channel that separates a refused permission from a cancel.
|
||||
*/
|
||||
export async function captureRegion(options: CaptureOptions): Promise<CaptureResult | null> {
|
||||
const platform = options.platform ?? process.platform;
|
||||
const hasCommand = options.hasCommand ?? commandExists;
|
||||
const run = options.run ?? runCommand;
|
||||
const baseRun = options.run ?? runCommand;
|
||||
|
||||
const backend = await selectBackend(
|
||||
hasCommand,
|
||||
@@ -246,10 +308,39 @@ export async function captureRegion(options: CaptureOptions): Promise<CaptureRes
|
||||
await mkdir(options.outputDir, { recursive: true });
|
||||
const output = join(options.outputDir, snipFileName(options.now ?? new Date()));
|
||||
|
||||
await backend.capture({ output, signal: options.signal, run });
|
||||
/** Everything the tools said, whether or not they exited cleanly. */
|
||||
const diagnostics: string[] = [];
|
||||
const run: RunCommand = async (tool, args, runOptions) => {
|
||||
try {
|
||||
const result = await baseRun(tool, args, runOptions);
|
||||
if (result.stderr.trim()) diagnostics.push(result.stderr.trim());
|
||||
return result;
|
||||
} catch (thrown) {
|
||||
const stderr = thrown instanceof CaptureToolError ? thrown.stderr : "";
|
||||
if (stderr.trim()) diagnostics.push(stderr.trim());
|
||||
throw thrown;
|
||||
}
|
||||
};
|
||||
|
||||
let failure: unknown;
|
||||
try {
|
||||
await backend.capture({ output, signal: options.signal, run });
|
||||
} catch (thrown) {
|
||||
failure = thrown;
|
||||
}
|
||||
|
||||
const written = await stat(output).catch(() => null);
|
||||
if (!written || written.size === 0) return null;
|
||||
if (written && written.size > 0) {
|
||||
return { path: output, bytes: written.size, backend: backend.name };
|
||||
}
|
||||
|
||||
return { path: output, bytes: written.size, backend: backend.name };
|
||||
const detail = diagnostics.join("\n");
|
||||
|
||||
if (SCREEN_RECORDING_DENIED.test(detail)) throw new CapturePermissionError(platform);
|
||||
|
||||
// A tool that explained itself is a failure worth reporting. Silence with no
|
||||
// image is the user pressing Escape, which is the common case and says nothing.
|
||||
if (detail) throw new CaptureToolError(backend.requires[0], detail, failure);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { environment } from "@vicinae/api";
|
||||
import { join } from "node:path";
|
||||
import { CapturePermissionError, NoCaptureToolError } from "./capture";
|
||||
import { errorMessage } from "./utils";
|
||||
|
||||
/**
|
||||
* The seam between the snip command and the chat command.
|
||||
@@ -53,3 +55,20 @@ export function readSnipContext(launchContext: unknown): SnipLaunchContext {
|
||||
sessionId: asString(sessionId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a capture failure into something worth showing.
|
||||
*
|
||||
* The two failures that carry a hint are the only two the user can do anything
|
||||
* about, and the hints are useless if they are dropped: "no capture tool is
|
||||
* installed" and "the system is blocking screen capture" need completely
|
||||
* different responses from completely different places.
|
||||
*/
|
||||
export function describeCaptureFailure(thrown: unknown): string {
|
||||
const actionable =
|
||||
thrown instanceof CapturePermissionError || thrown instanceof NoCaptureToolError
|
||||
? thrown.hint
|
||||
: undefined;
|
||||
|
||||
return actionable ? `${errorMessage(thrown)} ${actionable}` : errorMessage(thrown);
|
||||
}
|
||||
|
||||
+3
-5
@@ -1,8 +1,7 @@
|
||||
import { LaunchType, closeMainWindow, launchCommand, showHUD, type LaunchProps } from "@vicinae/api";
|
||||
import { NoCaptureToolError, captureRegion } from "./lib/capture";
|
||||
import { captureRegion } from "./lib/capture";
|
||||
import { getLamPreferences } from "./lib/preferences";
|
||||
import { readSnipContext, snipDirectory } from "./lib/snip";
|
||||
import { errorMessage } from "./lib/utils";
|
||||
import { describeCaptureFailure, readSnipContext, snipDirectory } from "./lib/snip";
|
||||
import { visionSupport } from "./lib/vision";
|
||||
|
||||
/**
|
||||
@@ -69,8 +68,7 @@ export default async function SnipCommand(props: LaunchProps) {
|
||||
const capture = await captureRegion({ outputDir: snipDirectory() });
|
||||
snipPath = capture?.path;
|
||||
} catch (thrown) {
|
||||
snipError =
|
||||
thrown instanceof NoCaptureToolError ? `${errorMessage(thrown)} ${thrown.hint}` : errorMessage(thrown);
|
||||
snipError = describeCaptureFailure(thrown);
|
||||
}
|
||||
|
||||
// An abandoned selection says nothing at all: the user pressed Escape. But a
|
||||
|
||||
Reference in New Issue
Block a user