fix: tell a refused screen capture apart from a cancelled one
The snip failed with "could not create image from rect". That message is macOS's signature for a denied Screen Recording permission, not a cancel, and it is the only thing distinguishing the two: both write no file, and screencapture -i has been reported to exit 0 when the permission is missing. The previous code could not tell them apart, so a permission problem would either look like Escape or, as here, surface as a bare "Command failed". - `RunCommand` now returns stdout and stderr, and `captureRegion` collects every message a tool produced, clean exit or not. - Success is still decided by the file landing on disk, so a tool that warns on stderr and writes the image anyway is still a success. - Failure is triaged from stderr: the macOS denial message becomes `CapturePermissionError` with the setting to change (TCC attributes the request to Vicinae, since it launched the extension runtime, so the permission belongs to Vicinae and needs an app restart). Any other complaint becomes a `CaptureToolError` rather than being swallowed. Silence with no image is a cancel, which is the common case. - `describeCaptureFailure` in `lib/snip.ts` keeps both actionable hints, which the old catch clause dropped for everything but a missing tool. Diagnosed from the process tree: the extension runtime (pid 66329) is a child of Vicinae (pid 66327), so the grant goes on Vicinae. Verified: 257 tests, tsc, eslint, prettier, and vici build.
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user