diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts index 8532dee0038a0..84fcb4cbf8277 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts @@ -427,6 +427,7 @@ import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopSnapShot from "./DesktopSnapShot.ts"; import * as SnapShotAccessibility from "./SnapShotAccessibility.ts"; +import { CaptureSetupRequiredError } from "./linuxCaptureSession.ts"; const accessibilityApp = { byPid: accessibilityByPidMock, @@ -1875,6 +1876,22 @@ it.effect("capture fails closed while snapshots are disabled", () => { ).pipe(Effect.provide(layerTest("win32"))); }); +it.effect("points capture setup failures at SnapShot settings", () => { + vi.stubEnv("XDG_SESSION_TYPE", "wayland"); + linuxCaptureMock.mockRejectedValueOnce(new CaptureSetupRequiredError("Install the helper.")); + return Effect.scoped( + Effect.gen(function* () { + const service = yield* DesktopSnapShot.make; + yield* service.configure(enabledSettings()); + const failure = yield* Effect.flip(service.capture); + assert.equal(failure.operation, "setup-required"); + }), + ).pipe( + Effect.provide(layerTest("linux", { makeDirectory: () => Effect.void })), + Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())), + ); +}); + it.effect( "keeps saved preferences but rechecks access and shortcut delivery after changing desktops", () => { diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.ts b/apps/desktop/src/snapShot/DesktopSnapShot.ts index e31d347084209..9dfcdc2f35a8f 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.ts @@ -50,6 +50,7 @@ import { niriSocketPath } from "./NiriSnapShot.ts"; import { CaptureShortcutConfig, niriCaptureConfigPath } from "./CaptureShortcutConfig.ts"; import type { PortalCaptureShortcut } from "./PortalCaptureShortcut.ts"; import { + CaptureSetupRequiredError, isGnomeCaptureSession, niriCaptureBinding, portalShortcutTrigger, @@ -128,6 +129,7 @@ const DesktopSnapShotOperation = Schema.Literals([ "disabled", "no-window-selected", "window-unavailable", + "setup-required", "capture", ]); @@ -155,6 +157,8 @@ export class DesktopSnapShotError extends Schema.TaggedError { expect((await setup.state()).status).toBe("not-installed"); expect(await NodeFSP.readFile(unrelated, "utf8")).toBe("keep"); }); +it("updates a helper left stale by an app update before capturing", async () => { + await setup.perform("install-hyprland-helper"); + await NodeFSP.writeFile(paths.bundle, "update"); + const result = await captureHyprlandWindow(paths); + expect(result.png).toEqual(png); + expect(await NodeFSP.readFile(hyprlandCaptureExecutable(paths), "utf8")).toBe("update"); + result.feedback?.close(); +}); +it("leaves first-time installation to explicit setup", async () => { + await expect(captureHyprlandWindow(paths)).rejects.toBeInstanceOf(CaptureSetupRequiredError); + await expect(NodeFSP.stat(hyprlandCaptureExecutable(paths))).rejects.toMatchObject({ + code: "ENOENT", + }); +}); it("does not overwrite a linked executable", async () => { const executable = hyprlandCaptureExecutable(paths); await NodeFSP.mkdir(NodePath.dirname(executable), { recursive: true }); diff --git a/apps/desktop/src/snapShot/HyprlandSnapShot.ts b/apps/desktop/src/snapShot/HyprlandSnapShot.ts index 4dc57e44448d1..a0b8b4181cd2a 100644 --- a/apps/desktop/src/snapShot/HyprlandSnapShot.ts +++ b/apps/desktop/src/snapShot/HyprlandSnapShot.ts @@ -7,7 +7,7 @@ import * as NodeURL from "node:url"; import * as Schema from "effect/Schema"; import type { DesktopCaptureHelperState } from "@t3tools/contracts"; import type { LinuxWindowSnapshot } from "./LinuxSnapShot.ts"; -import { readPortalPng } from "./linuxCaptureSession.ts"; +import { CaptureSetupRequiredError, readPortalPng } from "./linuxCaptureSession.ts"; import { startNativeCaptureFeedback } from "./NativeCaptureFeedback.ts"; import { HYPRLAND_CAPTURE_ACTION } from "./linuxCaptureSession.ts"; export { isHyprlandCaptureSession } from "./linuxCaptureSession.ts"; @@ -167,9 +167,15 @@ export async function captureHyprlandWindow( paths: HyprlandCapturePaths, options?: { readonly flash: boolean; readonly animate: boolean }, ): Promise { - const state = await new HyprlandCaptureSetup(paths).state(); - if (state.status !== "ready") - throw new Error(`${state.message} Open Settings → SnapShots to continue setup.`); + const setup = new HyprlandCaptureSetup(paths); + let state = await setup.state(); + if (state.status === "update-required") { + await setup.perform("install-hyprland-helper").catch((cause: unknown) => { + throw new CaptureSetupRequiredError(state.message, { cause }); + }); + state = await setup.state(); + } + if (state.status !== "ready") throw new CaptureSetupRequiredError(state.message); const executable = hyprlandCaptureExecutable(paths); const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-hyprland-capture-")); const cleanup = () => NodeFSP.rm(directory, { recursive: true, force: true }); diff --git a/apps/desktop/src/snapShot/KdeSnapShot.ts b/apps/desktop/src/snapShot/KdeSnapShot.ts index 50d9e700ee51e..2e5723f51aae0 100644 --- a/apps/desktop/src/snapShot/KdeSnapShot.ts +++ b/apps/desktop/src/snapShot/KdeSnapShot.ts @@ -10,7 +10,7 @@ import type { DesktopCaptureHelperState } from "@t3tools/contracts"; import { escapeDesktopEntryExecArgument } from "../app/DesktopLinuxUrlHandler.ts"; import type { LinuxWindowSnapshot } from "./LinuxSnapShot.ts"; -import { readPortalPng } from "./linuxCaptureSession.ts"; +import { CaptureSetupRequiredError, readPortalPng } from "./linuxCaptureSession.ts"; import { startNativeCaptureFeedback } from "./NativeCaptureFeedback.ts"; export { isKdeCaptureSession } from "./linuxCaptureSession.ts"; @@ -215,8 +215,7 @@ export async function captureKdeWindow( options?: { readonly flash: boolean; readonly animate: boolean }, ): Promise { const state = await new KdeCaptureSetup(paths).state(); - if (state.status !== "ready") - throw new Error(`${state.message} Open Settings → SnapShots to continue setup.`); + if (state.status !== "ready") throw new CaptureSetupRequiredError(state.message); const { executable } = kdeCapturePaths(paths); const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-kde-capture-")); let retained = false; diff --git a/apps/desktop/src/snapShot/linuxCaptureSession.ts b/apps/desktop/src/snapShot/linuxCaptureSession.ts index 59fe40b7c5212..11bf3984223df 100644 --- a/apps/desktop/src/snapShot/linuxCaptureSession.ts +++ b/apps/desktop/src/snapShot/linuxCaptureSession.ts @@ -7,6 +7,12 @@ import { nativeImage } from "electron"; // Keep dbus-next out of desktop detection and PNG reads in the main process. export const HYPRLAND_CAPTURE_ACTION = "capture-window"; + +export class CaptureSetupRequiredError extends Error { + constructor(state: string, options?: ErrorOptions) { + super(`${state} Open Settings → SnapShots to continue setup.`, options); + } +} export const NIRI_CAPTURE_PATH = "/com/t3tools/SnapShot"; export const NIRI_CAPTURE_INTERFACE = "com.t3tools.SnapShot";