diff --git a/client/src/App.test.tsx b/client/src/App.test.tsx
index a1f8830..0b8d12a 100644
--- a/client/src/App.test.tsx
+++ b/client/src/App.test.tsx
@@ -16,7 +16,7 @@
*/
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import type { ClientMessage, ServerChromeMedia, ServerDeck, ServerState } from "./protocol";
+import type { ClientMessage, ServerChromeMedia, ServerDeck, ServerMediaError, ServerState } from "./protocol";
/** Replace the real socket hook with a controllable fake. The chrome
* icon's job is to call ``send`` with the right message; the test
@@ -32,9 +32,16 @@ const onDeck = vi.fn<(m: ServerDeck) => void>();
* gate / focus restoration flow can set ``mockStatus`` to drive
* ``status`` through ``"unauthorized"`` → ``"open"`` transitions. */
let mockStatus: "connecting" | "open" | "closed" | "unauthorized" = "open";
+let mockAttempt = 0;
+let mockLastError = "";
+let mockCancelled = false;
+let mockRetrying = false;
const authenticate = vi.fn();
const deauthenticate = vi.fn();
+const cancelReconnect = vi.fn();
+const retryNow = vi.fn();
let sessionStateHandler: ((m: ServerState) => void) | null = null;
+let mediaErrorHandler: ((m: ServerMediaError) => void) | null = null;
vi.mock("./socket", () => ({
useDeckdSocket: (
deckCb: (m: ServerDeck) => void,
@@ -44,11 +51,13 @@ vi.mock("./socket", () => ({
_confirmRequestCb: unknown,
_runningWindowsCb: unknown,
sessionStateCb: ((m: ServerState) => void) | undefined,
+ mediaErrorCb: ((m: ServerMediaError) => void) | undefined,
_options: unknown,
) => {
onDeck.mockImplementation(deckCb);
chromeMediaHandler = chromeMediaCb ?? null;
sessionStateHandler = sessionStateCb ?? null;
+ mediaErrorHandler = mediaErrorCb ?? null;
return {
get status() {
return mockStatus;
@@ -57,12 +66,37 @@ vi.mock("./socket", () => ({
authenticate,
deauthenticate,
hasPassword: false,
+ get attempt() {
+ return mockAttempt;
+ },
+ get lastError() {
+ return mockLastError;
+ },
+ get cancelled() {
+ return mockCancelled;
+ },
+ get retrying() {
+ return mockRetrying;
+ },
+ cancelReconnect,
+ retryNow,
};
},
}));
import { App } from "./App";
+// Reconnect telemetry is module-level state shared by every describe block;
+// reset it before each test so a drop simulated in one test can't leak a
+// connection overlay into the next. Inner blocks still override as needed.
+beforeEach(() => {
+ mockStatus = "open";
+ mockAttempt = 0;
+ mockLastError = "";
+ mockCancelled = false;
+ mockRetrying = false;
+});
+
describe("App — chrome media icon", () => {
afterEach(cleanup);
beforeEach(() => {
@@ -1018,13 +1052,15 @@ describe("App — session lock takeover", () => {
expect(screen.getByRole("button", { name: /open/i })).toBeTruthy();
act(() => sessionStateHandler?.({ type: "state", locked: true, blanked: false }));
expect(screen.getAllByText("Screen locked").length).toBeGreaterThan(0);
- expect(screen.getByText(/Controls resume when you unlock/)).toBeTruthy();
+ // Issue #64: the takeover names the cause and that input is held.
+ expect(screen.getByText(/Screensaver or lock screen/)).toBeTruthy();
+ expect(screen.getByText(/Input is held until you unlock/)).toBeTruthy();
});
it("names the host in the lock copy", () => {
render();
act(() => sessionStateHandler?.({ type: "state", locked: true, blanked: false }));
- expect(screen.getByText(/localhost/)).toBeTruthy();
+ expect(screen.getAllByText(/localhost/).length).toBeGreaterThan(0);
});
it("keeps the connection dot's 'live' status while locked", () => {
@@ -1144,3 +1180,176 @@ describe("App — fullscreen toggle", () => {
expect(screen.getByRole("button", { name: "fullscreen" }).getAttribute("aria-pressed")).toBe("false");
});
});
+
+/* -------------------------------------------------------------------------
+ * Reconnect / disconnected overlay (issue #64): attempt count, last error,
+ * and a single Cancel/Retry affordance.
+ * --------------------------------------------------------------------- */
+
+describe("App — reconnect overlay", () => {
+ afterEach(cleanup);
+ beforeEach(() => {
+ send.mockReset();
+ cancelReconnect.mockReset();
+ retryNow.mockReset();
+ mockStatus = "open";
+ mockAttempt = 0;
+ mockLastError = "";
+ mockCancelled = false;
+ mockRetrying = false;
+ window.history.replaceState(null, "", "/?demo=default");
+ });
+
+ it("hides itself when the socket is open", () => {
+ render();
+ expect(screen.queryByText(/Reconnecting/)).toBeNull();
+ expect(screen.queryByText("Disconnected")).toBeNull();
+ });
+
+ it("shows attempt count and last error while reconnecting", () => {
+ mockStatus = "connecting";
+ mockAttempt = 2;
+ mockLastError = "daemon not reachable";
+ render();
+ expect(screen.getByText("Reconnecting…")).toBeTruthy();
+ expect(screen.getByText(/Attempt 2 — daemon not reachable/)).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Cancel reconnecting" })).toBeTruthy();
+ });
+
+ it("cancels the retry loop", () => {
+ mockStatus = "connecting";
+ mockAttempt = 1;
+ mockRetrying = true;
+ render();
+ fireEvent.click(screen.getByRole("button", { name: "Cancel reconnecting" }));
+ expect(cancelReconnect).toHaveBeenCalledTimes(1);
+ });
+
+ it("offers Retry once cancelled and re-arms the connection", () => {
+ mockStatus = "closed";
+ mockCancelled = true;
+ mockLastError = "connection lost (code 1006)";
+ render();
+ expect(screen.getByText("Disconnected")).toBeTruthy();
+ expect(screen.getByText("connection lost (code 1006)")).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: "Retry connection" }));
+ expect(retryNow).toHaveBeenCalledTimes(1);
+ });
+});
+
+/* -------------------------------------------------------------------------
+ * Deck load-error surface naming the deck and offender (issue #64).
+ * --------------------------------------------------------------------- */
+
+describe("App — deck error feedback", () => {
+ afterEach(cleanup);
+ beforeEach(() => {
+ send.mockReset();
+ onDeck.mockReset();
+ mockStatus = "open";
+ window.history.replaceState(null, "", "/?demo=default");
+ });
+
+ it("names the deck and offending widget, and returns to the focused app", () => {
+ render();
+ act(() => {
+ onDeck({
+ type: "deck",
+ app: "default",
+ jogstrip_enabled: true,
+ widgets: [],
+ error: "1 validation error for Deck",
+ error_deck: "firefox",
+ error_widget: "back",
+ });
+ });
+ expect(screen.getByText(/Couldn't load the "firefox" deck/)).toBeTruthy();
+ expect(screen.getByText(/Widget "back" is invalid/)).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: /return to focused app/i }));
+ expect(send).toHaveBeenCalledWith({ type: "clear_view" });
+ });
+
+ it("announces the deck error via the live region", () => {
+ render();
+ act(() => {
+ onDeck({
+ type: "deck",
+ app: "default",
+ jogstrip_enabled: true,
+ widgets: [],
+ error: "1 validation error for Deck",
+ error_deck: "firefox",
+ });
+ });
+ expect(screen.getByRole("status").textContent).toBe("Deck error: firefox");
+ });
+});
+
+/* -------------------------------------------------------------------------
+ * Media-command failure toast (issue #64): names the player and offers
+ * Retry (re-sends the last command) / Dismiss.
+ * --------------------------------------------------------------------- */
+
+describe("App — media error toast", () => {
+ afterEach(cleanup);
+ beforeEach(() => {
+ send.mockReset();
+ mediaErrorHandler = null;
+ mockStatus = "open";
+ window.history.replaceState(null, "", "/?demo=mpris");
+ });
+
+ it("surfaces a media_error frame naming the player", () => {
+ render();
+ act(() =>
+ mediaErrorHandler?.({
+ type: "media_error",
+ id: "mpris.vlc",
+ player: "VLC media player",
+ message: "player disappeared",
+ retryable: true,
+ }),
+ );
+ // The now-playing rows also name the player; the toast is the only
+ // element carrying the failure message.
+ expect(screen.getByText(/player disappeared/)).toBeTruthy();
+ expect(screen.getAllByText("VLC media player").length).toBeGreaterThan(0);
+ });
+
+ it("retries by re-sending the last command for that id", () => {
+ render();
+ // Issue a command through the real cell, so the App records it.
+ fireEvent.click(screen.getAllByLabelText("Next")[0]);
+ const sent = send.mock.calls[send.mock.calls.length - 1][0];
+ if (sent.type !== "media_command") throw new Error("expected media_command");
+
+ act(() =>
+ mediaErrorHandler?.({
+ type: "media_error",
+ id: sent.id,
+ player: "VLC media player",
+ message: "player rejected the command",
+ retryable: true,
+ }),
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Retry" }));
+ expect(send.mock.calls[send.mock.calls.length - 1][0]).toEqual(sent);
+ });
+
+ it("dismisses without re-sending", () => {
+ render();
+ const before = send.mock.calls.length;
+ act(() =>
+ mediaErrorHandler?.({
+ type: "media_error",
+ id: "mpris.vlc",
+ player: "VLC media player",
+ message: "player disappeared",
+ retryable: true,
+ }),
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
+ expect(screen.queryByText(/player disappeared/)).toBeNull();
+ expect(send.mock.calls.length).toBe(before);
+ });
+});
diff --git a/client/src/App.tsx b/client/src/App.tsx
index 838bff3..82d7f09 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -39,6 +39,7 @@ import type {
Icon as IconRef,
ServerChromeMedia,
ServerDeck,
+ ServerMediaError,
ServerRunningWindows,
ServerState,
WindowListEntry,
@@ -48,7 +49,9 @@ import { isTypingTarget, onActivate } from "./a11y";
import { Editor } from "./Editor";
import { ReflowHelp } from "./ReflowHelp";
import { ConfirmModal } from "./ConfirmModal";
-import type { Widget, ConfirmRequestMessage } from "./protocol";
+import { ConnectionOverlay } from "./ConnectionOverlay";
+import { MediaErrorToast } from "./MediaErrorToast";
+import type { Widget, ConfirmRequestMessage, MediaCommandMessage } from "./protocol";
import { wireWindowsToServer } from "./protocol";
import { pathForView, viewFromPath, type View } from "./view-routing";
@@ -203,6 +206,16 @@ export function App() {
// presses enabled so the first tap wakes the machine.
const [sessionState, setSessionState] = useState(null);
const onSessionState = useCallback((m: ServerState) => setSessionState(m), []);
+ // Media-command failure feedback (issue #64). The daemon answers a
+ // failed media command with a ``media_error`` frame naming the player;
+ // we hold the most recent one and render a toast. The *command* itself
+ // isn't echoed back, so the client remembers the last command it sent
+ // per id (``lastMediaCommandRef``) to make Retry possible.
+ const [mediaError, setMediaError] = useState(null);
+ const lastMediaCommandRef = useRef<
+ Record
+ >({});
+ const onMediaError = useCallback((m: ServerMediaError) => setMediaError(m), []);
// Demo mode has no socket, so seed the media store once on mount with the
// fixture readings — otherwise a media widget renders as "unavailable".
const isDemo = demoDeck !== null;
@@ -240,6 +253,7 @@ export function App() {
onConfirmRequest,
onRunningWindows,
onSessionState,
+ onMediaError,
{ enabled: !demoDeck && !isPlayground },
);
const playgroundSocket = usePlaygroundDaemon(
@@ -251,8 +265,19 @@ export function App() {
onRunningWindows,
{ enabled: isPlayground },
);
- const { status, send, authenticate, deauthenticate, hasPassword } =
- isPlayground ? playgroundSocket : realSocket;
+ const {
+ status,
+ send,
+ authenticate,
+ deauthenticate,
+ hasPassword,
+ attempt,
+ lastError,
+ cancelled,
+ retrying,
+ cancelReconnect,
+ retryNow,
+ } = isPlayground ? playgroundSocket : realSocket;
// Derived two-state (issue #160). ``locked`` requires credentials —
// full takeover of focus-targeting surfaces. ``blanked`` without
// ``locked`` is the soft "asleep, press anything to wake" state.
@@ -336,7 +361,10 @@ export function App() {
const padDrag = (state: "start" | "end") => send({ type: "pad_drag", id: TRACKPAD_ID, state });
const typeText = (text: string) => send({ type: "type", text });
const keyCombo = (combo: string) => send({ type: "key", combo });
- const mediaCommand = (id: string, command: "volume" | "seek" | "rate", value: number) => send({ type: "media_command", id, command, value });
+ const mediaCommand = (id: string, command: "volume" | "seek" | "rate", value: number) => {
+ lastMediaCommandRef.current[id] = { command, value };
+ send({ type: "media_command", id, command, value });
+ };
// Now-playing per-row transport (issue #54): the cell sends
// three value-less commands — ``play-pause`` / ``next`` / ``previous``
// — keyed by the row's ``mpris.`` id. The server routes the
@@ -344,8 +372,25 @@ export function App() {
// ``media_command`` family keeps going to the VLC path. This
// callback is the only thing the cell needs to know about the
// wire surface.
- const nowPlayingCommand = (id: string, command: "play-pause" | "next" | "previous") =>
+ const nowPlayingCommand = (id: string, command: "play-pause" | "next" | "previous") => {
+ lastMediaCommandRef.current[id] = { command };
send({ type: "media_command", id, command });
+ };
+ // Retry re-sends the last command issued for the failed id (issue #64).
+ // The toast is cleared first so the retry is visibly a fresh attempt; a
+ // second failure re-opens it with the daemon's latest message.
+ const retryMedia = useCallback(() => {
+ const failed = mediaError;
+ setMediaError(null);
+ if (!failed) return;
+ const last = lastMediaCommandRef.current[failed.id];
+ if (!last) return;
+ send(
+ last.value === undefined
+ ? { type: "media_command", id: failed.id, command: last.command }
+ : { type: "media_command", id: failed.id, command: last.command, value: last.value },
+ );
+ }, [mediaError, send]);
// Chrome view toggle (issue #51): the media icon mirrors the existing
// trackpad / settings buttons. When opened it sends ``select_view``
// so the daemon pushes the mpris deck; when closed it sends
@@ -608,10 +653,21 @@ export function App() {
if (prevDeck.current === deck) return;
prevDeck.current = deck;
if (!deck) return;
+ if (deck.error) {
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setLiveText(`Deck error: ${deck.error_deck || "unknown deck"}`);
+ return;
+ }
const app = deck.display_name?.trim() || deck.app || "deckd";
- // eslint-disable-next-line react-hooks/set-state-in-effect
setLiveText(`Deck: ${app}${programSuffix}`);
}, [deck, programSuffix]);
+ // Announce a failed media command (issue #64) — same live region, so a
+ // screen reader hears the toast without a separate focus move.
+ useEffect(() => {
+ if (!mediaError) return;
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setLiveText(`Media error: ${mediaError.player || mediaError.id}`);
+ }, [mediaError]);
// Chrome app-identity badge (ADR-0007): the daemon relays an
// optional ``display_name`` / ``theme`` / ``icon`` per deck; the
@@ -675,6 +731,14 @@ export function App() {
const lockBlockedView = view === "deck" || view === "trackpad";
const renderLockTakeover = screenLocked && lockBlockedView;
const renderBlankBanner = screenBlanked && !screenLocked && view === "deck";
+ // Reconnect / disconnected overlay (issue #64). Shown only once there's
+ // actual trouble — a drop, a recorded error, or an active retry loop — so
+ // the initial dial doesn't flash an overlay over the first paint. Demo and
+ // playground report an ``open`` socket with no retry, so they never show
+ // it; the lock takeover outranks it.
+ const reconnecting = !cancelled && (status === "connecting" || retrying);
+ const showConnectionOverlay =
+ !screenLocked && (retrying || status === "closed" || !!lastError);
return (
<>
@@ -697,12 +761,28 @@ export function App() {
}
>
{headingText}
+ {/* Reconnect / disconnected overlay (issue #64). Sits over the
+ last-rendered surface so a stale grid stays visible behind it
+ while naming the attempt count and last error. Never shown
+ while locked (the lock takeover owns the surface then). */}
+ {showConnectionOverlay && (
+
+ )}
{renderLockTakeover ? (
Screen locked
+
+ {`Screensaver or lock screen active on ${HOSTNAME}`}
+
- {`Controls resume when you unlock ${HOSTNAME}`}
+ {`Input is held until you unlock ${HOSTNAME}`}
Deck error
+ {(deck.error_deck || deck.error_widget) && (
+
+ {deck.error_deck
+ ? `Couldn't load the "${deck.error_deck}" deck.`
+ : "Couldn't load a deck."}
+ {deck.error_widget
+ ? ` Widget "${deck.error_widget}" is invalid.`
+ : ""}
+
+ )}
+ {deck.error === "view not found" && (
+
+ That view isn't available on this daemon.
+
+ )}
{deck.error}
+
) : deck ? (
) : null}
+ {/* Failed-media-command toast (issue #64). Non-blocking: the deck
+ stays usable and the toast names the player with Retry / Dismiss. */}
+ {mediaError ? (
+ setMediaError(null)}
+ />
+ ) : null}
>
);
}
diff --git a/client/src/ConnectionOverlay.tsx b/client/src/ConnectionOverlay.tsx
new file mode 100644
index 0000000..385d678
--- /dev/null
+++ b/client/src/ConnectionOverlay.tsx
@@ -0,0 +1,60 @@
+/** Full-surface reconnect / disconnected overlay (issue #64).
+ *
+ * The socket hook owns the facts — whether the retry loop is active, the
+ * attempt count, and the last error — and this component turns them into
+ * copy plus a single Cancel/Retry affordance. It renders *over* whatever
+ * the surface was last showing, so a stale grid stays readable behind it.
+ *
+ * Deliberately ``aria-live="polite"`` without ``role="status"``: the app
+ * already keeps one ``role="status"`` live region for announcements, and a
+ * second implicit status role would make ``getByRole("status")`` ambiguous
+ * for both users and tests. The spinner stops under the app's
+ * ``a11y-reduce-motion`` class and the OS ``prefers-reduced-motion`` query.
+ */
+export function ConnectionOverlay({
+ reconnecting,
+ attempt,
+ lastError,
+ onCancel,
+ onRetry,
+}: {
+ /** The auto-retry loop is active (dialing or waiting out the backoff).
+ * False means the loop is paused/stopped, so offer Retry. */
+ reconnecting: boolean;
+ attempt: number;
+ lastError: string;
+ onCancel: () => void;
+ onRetry: () => void;
+}) {
+ const detail = lastError || "contacting the daemon…";
+ return (
+
+ );
+}
diff --git a/client/src/MediaErrorToast.tsx b/client/src/MediaErrorToast.tsx
new file mode 100644
index 0000000..3c96613
--- /dev/null
+++ b/client/src/MediaErrorToast.tsx
@@ -0,0 +1,40 @@
+/** Non-blocking toast for a failed media command (issue #64).
+ *
+ * The daemon sends a ``media_error`` frame naming the player and whether a
+ * retry is worth attempting; this renders it above the bottom chrome with
+ * Retry / Dismiss. It never blocks the grid — a rejected volume tap should
+ * not cost you the rest of the deck. ``aria-live="polite"`` announces it.
+ */
+export function MediaErrorToast({
+ player,
+ message,
+ retryable,
+ onRetry,
+ onDismiss,
+}: {
+ player: string;
+ message: string;
+ retryable: boolean;
+ onRetry: () => void;
+ onDismiss: () => void;
+}) {
+ return (
+
+ );
+}
diff --git a/client/src/playground/usePlaygroundDaemon.ts b/client/src/playground/usePlaygroundDaemon.ts
index 3f7bb02..cc8f007 100644
--- a/client/src/playground/usePlaygroundDaemon.ts
+++ b/client/src/playground/usePlaygroundDaemon.ts
@@ -67,12 +67,19 @@ export function usePlaygroundDaemon(
}, []);
// Report "open" so the chrome connection indicator reads "live", matching
- // how demo mode presents a fixture as a live surface.
+ // how demo mode presents a fixture as a live surface. The reconnect
+ // telemetry (issue #64) is inert — the MockDaemon can't disconnect.
return {
status: "open" as const,
send,
authenticate: noop,
deauthenticate: noop,
hasPassword: false,
+ attempt: 0,
+ lastError: "",
+ cancelled: false,
+ retrying: false,
+ cancelReconnect: noop,
+ retryNow: noop,
};
}
diff --git a/client/src/protocol.generated.ts b/client/src/protocol.generated.ts
index c922747..c336ada 100644
--- a/client/src/protocol.generated.ts
+++ b/client/src/protocol.generated.ts
@@ -77,6 +77,17 @@ export type DeckMessage = {
* decks live so a fix on disk restores service without a restart.
*/
web_app?: boolean;
+ /**
+ * Structured companions to ``error`` (issue #64): the human-readable
+ * deck name and the offending widget, when the daemon could identify
+ * them. The raw ``error`` text (a pydantic dump) is precise but
+ * machine-shaped; these let the client say *which deck* and *which
+ * widget* in plain language. Both are ``None`` for non-deck errors
+ * (e.g. a chrome-view not found) and when the location is unknown.
+ */
+ error?: string | null;
+
+ error_deck?: string | null;
/**
* The currently focused app's identity, populated when the daemon has a
* focus backend (never in headless mode). ``None`` before the first focus
@@ -84,7 +95,7 @@ export type DeckMessage = {
* prefill ``match`` tokens for the detect-and-offer prompt and the
* browser-vs-site branch.
*/
- error?: string | null;
+ error_widget?: string | null;
/**
* True only on a genuine focus-driven fallback to the default deck —
* i.e. the resolution missed every loaded deck and ``store.default()``
@@ -214,6 +225,42 @@ export type ErrorMessage = {
reason: string;
};
+/**
+ * Daemon -> client push: a media command the client issued failed
+ * (issue #64).
+ *
+ * The client's media surfaces (the VLC ``media`` widget and the
+ * ``nowplaying`` rows) are fire-and-forget: a command to a player that
+ * disappeared between the last snapshot and the tap, or one the player
+ * rejected, previously vanished into a daemon log. This frame tells the
+ * *requesting* session what happened so it can explain which player
+ * was affected and offer a retry — it is not broadcast (the failure is
+ * a consequence of one session's action).
+ *
+ * ``id`` echoes the widget / row id the command targeted (the same id
+ * the client sent), so the client can correlate and re-send. ``player``
+ * is the human-readable player name (MPRIS ``Identity``, or the VLC
+ * widget's label); the daemon falls back to the raw id when it can't
+ * name the player, but the field stays nullable so an older server
+ * leaves the client's own fallback reachable. ``retryable`` is ``True``
+ * for every failure the daemon reports today (a vanished player or a
+ * rejected call can both succeed on a retry) and defaults to ``True``;
+ * it is reserved for a future failure a retry can't help.
+ *
+ */
+export type MediaErrorMessage = {
+
+ type: "media_error";
+
+ id: string;
+
+ player?: string | null;
+
+ message: string;
+
+ retryable?: boolean;
+};
+
/**
* Daemon -> client push: the chrome media icon's passive
* playback-state snapshot (issue #47).
@@ -414,7 +461,7 @@ export type RunningWindowsMessage = {
windows: WindowListEntry[];
};
-export type ServerMessage = DeckMessage | StateMessage | BrightnessMessage | WidgetUpdateMessage | MediaStateMessage | ChromeMediaMessage | EventMessage | MacroResultMessage | ConfirmRequestMessage | RunningWindowsMessage | ErrorMessage;
+export type ServerMessage = DeckMessage | StateMessage | BrightnessMessage | WidgetUpdateMessage | MediaStateMessage | MediaErrorMessage | ChromeMediaMessage | EventMessage | MacroResultMessage | ConfirmRequestMessage | RunningWindowsMessage | ErrorMessage;
export type HelloMessage = {
diff --git a/client/src/protocol.ts b/client/src/protocol.ts
index 5516c47..d8ea28f 100644
--- a/client/src/protocol.ts
+++ b/client/src/protocol.ts
@@ -27,6 +27,7 @@ import type {
MediaStateMessage,
ChromeMediaMessage,
ErrorMessage,
+ MediaErrorMessage,
MacroResultMessage,
ConfirmRequestMessage,
RunningWindowsMessage,
@@ -58,6 +59,7 @@ export type {
BrightnessMessage,
WidgetUpdateMessage,
MediaStateMessage,
+ MediaErrorMessage,
ChromeMediaMessage,
EventMessage,
MacroResultMessage,
@@ -181,6 +183,11 @@ export interface ServerDeck {
icon?: Icon | null;
web_app?: boolean;
error?: string | null;
+ /** Structured companions to ``error`` (issue #64): the human deck name
+ * and offending widget, when the daemon could identify them. Both are
+ * ``null``/absent for non-deck errors (e.g. a chrome view not found). */
+ error_deck?: string | null;
+ error_widget?: string | null;
focused_app?: FocusedAppInfo | null;
is_default?: boolean;
}
@@ -189,6 +196,7 @@ export type ServerBrightness = BrightnessMessage;
export type ServerWidgetUpdate = WidgetUpdateMessage;
export type ServerChromeMedia = ChromeMediaMessage;
export type ServerError = ErrorMessage;
+export type ServerMediaError = MediaErrorMessage;
export type ServerMacroResult = MacroResultMessage;
export type ServerConfirmRequest = ConfirmRequestMessage;
// WindowListEntry carries an opaque ``icon`` field on the wire but the
diff --git a/client/src/socket.test.ts b/client/src/socket.test.ts
new file mode 100644
index 0000000..0d16ac8
--- /dev/null
+++ b/client/src/socket.test.ts
@@ -0,0 +1,129 @@
+/** Reconnect telemetry on the socket hook (issue #64).
+ *
+ * The App renders the reconnecting overlay from facts the hook owns:
+ * the attempt counter, the last error, and whether the user cancelled the
+ * auto-retry loop. These tests drive a stub WebSocket so those facts can
+ * be asserted without a daemon.
+ */
+import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { useDeckdSocket } from "./socket";
+
+class FakeWebSocket {
+ static instances: FakeWebSocket[] = [];
+ static OPEN = 1;
+ url: string;
+ readyState = 0;
+ sent: string[] = [];
+ onopen: (() => void) | null = null;
+ onclose: ((ev: { code: number; reason: string }) => void) | null = null;
+ onmessage: ((ev: { data: string }) => void) | null = null;
+ onerror: (() => void) | null = null;
+
+ constructor(url: string) {
+ this.url = url;
+ FakeWebSocket.instances.push(this);
+ }
+ send(data: string) {
+ this.sent.push(data);
+ }
+ close() {
+ this.readyState = 3;
+ }
+ open() {
+ this.readyState = FakeWebSocket.OPEN;
+ this.onopen?.();
+ }
+ fail(code: number, reason = "") {
+ this.readyState = 3;
+ this.onclose?.({ code, reason });
+ }
+}
+
+const noop = () => {};
+
+function hookArgs() {
+ // Stable identities: a fresh callback per render would re-run the hook's
+ // connect effect and spin up a feedback loop of sockets.
+ return [noop, noop, noop] as const;
+}
+
+describe("useDeckdSocket — reconnect feedback (issue #64)", () => {
+ beforeEach(() => {
+ FakeWebSocket.instances = [];
+ vi.stubGlobal("WebSocket", FakeWebSocket);
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(() => Promise.resolve(new Response(null, { status: 200 }))),
+ );
+ window.history.replaceState(null, "", "/");
+ });
+ afterEach(() => {
+ // Vitest globals are off, so RTL's auto-cleanup can't register itself;
+ // unmount explicitly or a pending retry timer leaks into the next test.
+ cleanup();
+ vi.unstubAllGlobals();
+ });
+
+ it("counts a failed health gate as attempt 1", async () => {
+ vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new Error("down"))));
+ const { result } = renderHook(() => useDeckdSocket(...hookArgs()));
+ await waitFor(() =>
+ expect(result.current.lastError).toBe("daemon not reachable"),
+ );
+ expect(result.current.attempt).toBe(1);
+ expect(result.current.retrying).toBe(true);
+ expect(FakeWebSocket.instances.length).toBe(0);
+ });
+
+ it("resets attempt on open and records the last error on close", async () => {
+ const { result } = renderHook(() => useDeckdSocket(...hookArgs()));
+ await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
+ act(() => FakeWebSocket.instances[0].open());
+ expect(result.current.status).toBe("open");
+ expect(result.current.attempt).toBe(0);
+ expect(result.current.lastError).toBe("");
+
+ act(() => FakeWebSocket.instances[0].fail(1006));
+ expect(result.current.status).toBe("closed");
+ expect(result.current.lastError).toBe("daemon not reachable");
+ });
+
+ it("counts attempts as the retry loop runs", async () => {
+ const { result } = renderHook(() => useDeckdSocket(...hookArgs()));
+ await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
+ act(() => FakeWebSocket.instances[0].open());
+ act(() => FakeWebSocket.instances[0].fail(1006));
+
+ // The 500 ms backoff fires and opens a second socket.
+ await waitFor(
+ () => expect(FakeWebSocket.instances.length).toBe(2),
+ { timeout: 2000 },
+ );
+ await waitFor(
+ () => expect(result.current.attempt).toBe(1),
+ { timeout: 2000 },
+ );
+ });
+
+ it("cancel stops the retry loop; retry resumes it", async () => {
+ const { result } = renderHook(() => useDeckdSocket(...hookArgs()));
+ await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
+ act(() => FakeWebSocket.instances[0].open());
+ act(() => FakeWebSocket.instances[0].fail(1006));
+
+ act(() => result.current.cancelReconnect());
+ expect(result.current.cancelled).toBe(true);
+
+ // No new socket while cancelled, even after the backoff window.
+ await new Promise((resolve) => setTimeout(resolve, 700));
+ expect(FakeWebSocket.instances.length).toBe(1);
+
+ act(() => result.current.retryNow());
+ await waitFor(
+ () => expect(FakeWebSocket.instances.length).toBe(2),
+ { timeout: 2000 },
+ );
+ expect(result.current.cancelled).toBe(false);
+ });
+});
diff --git a/client/src/socket.ts b/client/src/socket.ts
index d003c2f..6679e0c 100644
--- a/client/src/socket.ts
+++ b/client/src/socket.ts
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
-import type { ClientMessage, ServerChromeMedia, ServerConfirmRequest, ServerDeck, ServerMessage, ServerRunningWindows, ServerState, ServerWidgetUpdate, MediaState } from "./protocol";
+import type { ClientMessage, ServerChromeMedia, ServerConfirmRequest, ServerDeck, ServerMediaError, ServerMessage, ServerRunningWindows, ServerState, ServerWidgetUpdate, MediaState } from "./protocol";
import { wireDeckToServer, wireWindowsToServer } from "./protocol";
type Status = "connecting" | "open" | "closed" | "unauthorized";
@@ -41,6 +41,16 @@ function readPinnedDeck(): string {
}
}
+/** Human-readable one-liner for a socket close, shown in the reconnect
+ * overlay (issue #64). Keyed off the WebSocket close code so a dropped
+ * daemon reads differently from a deliberate close. */
+function describeClose(code: number, reason: string): string {
+ if (code === 1006) return "daemon not reachable";
+ if (code === 1000) return "connection closed";
+ const suffix = reason ? ` — ${reason}` : "";
+ return `connection lost (code ${code})${suffix}`;
+}
+
export function useDeckdSocket(
onDeck: (m: ServerDeck) => void,
onWidgetUpdate: (m: ServerWidgetUpdate) => void,
@@ -49,6 +59,7 @@ export function useDeckdSocket(
onConfirmRequest?: (m: ServerConfirmRequest) => void,
onRunningWindows?: (m: ServerRunningWindows) => void,
onSessionState?: (m: ServerState) => void,
+ onMediaError?: (m: ServerMediaError) => void,
options: { enabled?: boolean } = {},
) {
const { enabled = true } = options;
@@ -68,14 +79,38 @@ export function useDeckdSocket(
const unauthorizedRef = useRef(false);
// Bumped by ``authenticate`` to force the connect effect to re-run.
const [gen, setGen] = useState(0);
+ // Reconnect feedback (issue #64). ``attempt`` counts connect attempts
+ // since the last successful open; ``lastError`` is the human-readable
+ // reason for the latest failure; ``cancelled`` is true after the user
+ // presses Cancel and pauses the auto-retry loop. All three drive the
+ // reconnecting overlay.
+ const [attempt, setAttempt] = useState(0);
+ const [lastError, setLastError] = useState("");
+ const [cancelled, setCancelled] = useState(false);
+ // True while the auto-retry loop is in flight (dialing or waiting out the
+ // backoff) and not cancelled. Distinguishes "actively reconnecting" from
+ // "given up" for the overlay: a drop parks in ``closed`` for the whole
+ // backoff, so ``status`` alone would mislabel the wait as Disconnected.
+ const [retrying, setRetrying] = useState(false);
+ const attemptRef = useRef(0);
+ // Read inside the effect's scheduler; a plain ref so a Cancel can stop
+ // a pending timer without re-running the effect (which would tear down
+ // the socket that's already gone).
+ const cancelledRef = useRef(false);
useEffect(() => {
if (!enabled) return;
+ if (cancelledRef.current) {
+ setStatus("closed");
+ return;
+ }
let stopped = false;
let timer: number | undefined;
const connect = () => {
- if (stopped) return;
+ if (stopped || cancelledRef.current) return;
+ attemptRef.current += 1;
+ setAttempt(attemptRef.current);
const ws_url = resolve_ws_url();
let ws: WebSocket;
try {
@@ -83,6 +118,8 @@ export function useDeckdSocket(
} catch (err) {
console.error("invalid deckd WebSocket URL", ws_url, err);
setStatus("closed");
+ setLastError("could not open a connection");
+ setRetrying(true);
timer = window.setTimeout(connect, Math.min(backoffRef.current, 8000));
backoffRef.current *= 2;
return;
@@ -92,7 +129,11 @@ export function useDeckdSocket(
ws.onopen = () => {
setStatus("open");
+ setRetrying(false);
backoffRef.current = 500;
+ attemptRef.current = 0;
+ setAttempt(0);
+ setLastError("");
const password = passwordRef.current;
const pinnedDeck = readPinnedDeck();
const hello: ClientMessage = {
@@ -138,6 +179,11 @@ export function useDeckdSocket(
else if (msg.type === "state" && onSessionState) {
onSessionState(msg);
}
+ // Issue #64: a media command this session issued failed. Surface
+ // it so the client can name the player and offer a retry.
+ else if (msg.type === "media_error" && onMediaError) {
+ onMediaError(msg);
+ }
else if (msg.type === "error" && msg.reason === "unauthorized") {
// Wrong/absent password: stop reconnecting and prompt the user.
unauthorizedRef.current = true;
@@ -166,11 +212,13 @@ export function useDeckdSocket(
setStatus("unauthorized");
return;
}
- if (stopped || unauthorizedRef.current) {
+ if (stopped || unauthorizedRef.current || cancelledRef.current) {
if (!unauthorizedRef.current) setStatus("closed");
return;
}
setStatus("closed");
+ setLastError(describeClose(ev.code, ev.reason));
+ setRetrying(true);
const wait = Math.min(backoffRef.current, 8000);
backoffRef.current = wait * 2;
timer = window.setTimeout(connect, wait);
@@ -185,12 +233,18 @@ export function useDeckdSocket(
// doesn't log a connection error when the daemon is still starting up
// (the `just dev` recipe starts daemon and Vite simultaneously).
const healthUrl = resolve_health_url();
- // eslint-disable-next-line react-hooks/set-state-in-effect
setStatus("connecting");
fetch(healthUrl)
.then(() => connect())
.catch(() => {
- if (!stopped) {
+ if (!stopped && !cancelledRef.current) {
+ setStatus("closed");
+ setLastError("daemon not reachable");
+ // The health gate failing is itself a connection attempt, so the
+ // overlay reads "Attempt 1" rather than "Attempt 0".
+ attemptRef.current += 1;
+ setAttempt(attemptRef.current);
+ setRetrying(true);
timer = window.setTimeout(() => connect(), 1000);
}
});
@@ -199,22 +253,31 @@ export function useDeckdSocket(
if (timer) window.clearTimeout(timer);
wsRef.current?.close();
};
- }, [onDeck, onWidgetUpdate, onMediaState, onChromeMedia, onConfirmRequest, onRunningWindows, onSessionState, enabled, gen]);
+ }, [onDeck, onWidgetUpdate, onMediaState, onChromeMedia, onConfirmRequest, onRunningWindows, onSessionState, onMediaError, enabled, gen]);
const send = (msg: ClientMessage) => {
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
};
+ // Clear the reconnect latches and kick off a fresh connect. Shared by the
+ // password actions and by the overlay's Retry so the flag/backoff/gen reset
+ // lives in exactly one place.
+ const restartConnection = useCallback(() => {
+ cancelledRef.current = false;
+ setCancelled(false);
+ backoffRef.current = 500;
+ setGen((g) => g + 1);
+ }, []);
+
// Store a new password and force an immediate reconnect that presents it.
const authenticate = useCallback((password: string) => {
passwordRef.current = password;
storePassword(password);
setHasPassword(!!password);
unauthorizedRef.current = false;
- backoffRef.current = 500;
- setGen((g) => g + 1);
- }, []);
+ restartConnection();
+ }, [restartConnection]);
// Forget the stored password and reconnect with none — the daemon (auth on)
// then rejects and the gate reappears. A no-op-looking reconnect if the
@@ -224,11 +287,43 @@ export function useDeckdSocket(
storePassword("");
setHasPassword(false);
unauthorizedRef.current = false;
- backoffRef.current = 500;
- setGen((g) => g + 1);
+ restartConnection();
+ }, [restartConnection]);
+
+ // Pause the automatic reconnect loop (issue #64). The pending retry
+ // timer bails on ``cancelledRef``, so the loop stops without needing to
+ // reach into the effect's local timer handle.
+ const cancelReconnect = useCallback(() => {
+ cancelledRef.current = true;
+ setCancelled(true);
+ setRetrying(false);
+ setStatus("closed");
+ wsRef.current?.close();
}, []);
- return { status, send, authenticate, deauthenticate, hasPassword };
+ // Resume immediately from a cancelled or closed state (issue #64). The
+ // generation bump tears down the old effect and starts a fresh connect
+ // (including the /health gate) right away.
+ const retryNow = useCallback(() => {
+ attemptRef.current = 0;
+ setAttempt(0);
+ setLastError("");
+ restartConnection();
+ }, [restartConnection]);
+
+ return {
+ status,
+ send,
+ authenticate,
+ deauthenticate,
+ hasPassword,
+ attempt,
+ lastError,
+ cancelled,
+ retrying,
+ cancelReconnect,
+ retryNow,
+ };
}
function resolve_health_url(): string {
diff --git a/client/src/style.css b/client/src/style.css
index 034e484..461e524 100644
--- a/client/src/style.css
+++ b/client/src/style.css
@@ -97,6 +97,8 @@ button:focus-visible {
padding: 10px;
min-height: 0;
min-width: 0;
+ /* Anchors the reconnect overlay (issue #64). */
+ position: relative;
}
.chrome-jogstrip {
@@ -754,6 +756,166 @@ button:focus-visible {
max-width: 30ch;
}
+.lock-takeover-cause {
+ color: #f0b429;
+ font-size: calc(13px * var(--label-scale, 1));
+ max-width: 34ch;
+}
+
+/* ---------------------------------------------------------------------------
+ * Reconnect / disconnected overlay (issue #64). Sits over the last-rendered
+ * surface, naming the attempt count and the last error, with a single
+ * Cancel/Retry control. The spinner is animation-only and stops under
+ * prefers-reduced-motion (both the OS query and the per-device class).
+ * ------------------------------------------------------------------------- */
+.conn-overlay {
+ position: absolute;
+ inset: 10px;
+ z-index: 5;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: color-mix(in srgb, #0b0f14 72%, transparent);
+ backdrop-filter: blur(3px);
+ border-radius: 16px;
+}
+
+.conn-overlay-card {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 10px;
+ padding: 22px 26px;
+ border-radius: 14px;
+ background: #11161b;
+ border: 1px solid #2a333d;
+ text-align: center;
+ max-width: 34ch;
+}
+
+.conn-overlay-spinner {
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ border: 3px solid #2a333d;
+ border-top-color: #7dd3fc;
+ animation: conn-spin 900ms linear infinite;
+}
+
+@keyframes conn-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.conn-overlay-title {
+ font-size: calc(17px * var(--label-scale, 1));
+ font-weight: 700;
+ color: #e6ebf0;
+}
+
+.conn-overlay-sub {
+ font-size: calc(13px * var(--label-scale, 1));
+ color: #8a96a3;
+}
+
+.conn-overlay-btn {
+ margin-top: 2px;
+ padding: 8px 18px;
+ border-radius: 10px;
+ border: 1px solid #2a333d;
+ background: #1b222a;
+ color: #e6ebf0;
+ font-weight: 600;
+}
+
+.conn-overlay-btn:active {
+ transform: scale(0.97);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .conn-overlay-spinner {
+ animation: none;
+ }
+ .conn-overlay-btn:active {
+ transform: none;
+ }
+ .media-error-toast {
+ animation: none;
+ }
+ .deck-error-return:active {
+ transform: none;
+ }
+}
+
+.a11y-reduce-motion .conn-overlay-spinner {
+ animation: none;
+}
+
+.a11y-reduce-motion .conn-overlay-btn:active {
+ transform: none;
+}
+
+/* ---------------------------------------------------------------------------
+ * Media-command failure toast (issue #64). Non-blocking, above the bottom
+ * chrome, with Retry / Dismiss. Slide-in is disabled under reduced motion.
+ * ------------------------------------------------------------------------- */
+.media-error-toast {
+ position: fixed;
+ left: 50%;
+ bottom: calc(64px + env(safe-area-inset-bottom));
+ transform: translateX(-50%);
+ z-index: 30;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ max-width: min(92vw, 520px);
+ padding: 10px 14px;
+ border-radius: 12px;
+ background: #2a1b1b;
+ border: 1px solid #7f1d1d;
+ color: #fecaca;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
+ animation: media-toast-in 160ms ease-out;
+}
+
+@keyframes media-toast-in {
+ from {
+ opacity: 0;
+ transform: translate(-50%, 8px);
+ }
+}
+
+.media-error-text {
+ font-size: calc(13px * var(--label-scale, 1));
+ line-height: 1.35;
+}
+
+.media-error-player {
+ font-weight: 700;
+ color: #fecaca;
+}
+
+.media-error-actions {
+ display: flex;
+ gap: 6px;
+ flex-shrink: 0;
+}
+
+.media-error-btn {
+ padding: 6px 12px;
+ border-radius: 8px;
+ border: 1px solid #7f1d1d;
+ background: #3a2222;
+ color: #fee2e2;
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.a11y-reduce-motion .media-error-toast {
+ animation: none;
+}
+
.lock-banner {
display: flex;
align-items: center;
@@ -1023,6 +1185,33 @@ button:focus-visible {
color: #fecaca;
}
+.deck-error-summary {
+ font-size: clamp(13px, 2.2vw, 16px);
+ color: #fecaca;
+}
+
+.deck-error-summary strong {
+ color: #fff;
+}
+
+.deck-error-return {
+ align-self: flex-start;
+ padding: 8px 14px;
+ border-radius: 10px;
+ border: 1px solid #7f1d1d;
+ background: #2a1b1b;
+ color: #fee2e2;
+ font-weight: 600;
+}
+
+.deck-error-return:active {
+ transform: scale(0.97);
+}
+
+.a11y-reduce-motion .deck-error-return:active {
+ transform: none;
+}
+
/* ---------------------------------------------------------------------------
* Password gate (issue #16): full-surface landing shown when the daemon
* rejects a remote connection as unauthorized. A single password field.
diff --git a/daemon/deckd/decks.py b/daemon/deckd/decks.py
index 4c7b430..d6a9dc0 100644
--- a/daemon/deckd/decks.py
+++ b/daemon/deckd/decks.py
@@ -464,12 +464,101 @@ def matches_identity(self, app: AppInfo) -> bool:
return False
+class DeckLoadError(SystemExit):
+ """A deck file failed to load — YAML syntax or schema violation
+ (issue #64).
+
+ ``str(exc)`` stays the precise, machine-shaped message (the pydantic
+ dump, kept for logs and the wire's ``error`` field). ``deck`` and
+ ``widget`` are best-effort *human* identifiers so the client can say
+ which deck and which widget are affected without parsing the dump.
+ """
+
+ def __init__(
+ self,
+ message: str,
+ *,
+ deck: str | None = None,
+ widget: str | None = None,
+ ) -> None:
+ super().__init__(message)
+ self.message = message
+ self.deck = deck
+ self.widget = widget
+
+
+def _identify_deck(data: Any, path: Path) -> str:
+ """Best-effort human deck name from the raw (unvalidated) YAML.
+
+ The file has already failed validation, so there's no ``Deck`` object
+ to read an id from. Prefer the authored ``id`` / ``display_name`` /
+ first ``match`` token, falling back to the filename stem (which is
+ how a broken deck is easiest to find on disk).
+ """
+ if isinstance(data, dict):
+ for key in ("id", "display_name"):
+ value = data.get(key)
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ match = data.get("match")
+ if (
+ isinstance(match, list)
+ and match
+ and isinstance(match[0], str)
+ and match[0].strip()
+ ):
+ return match[0].strip()
+ return path.stem
+
+
+def _identify_widget(data: Any, errors: list[Any]) -> str | None:
+ """Best-effort offending-widget name from a pydantic error list.
+
+ A field error under ``widgets.`` names the widget by its authored
+ ``id`` (or ``#`` when even that is missing). A model-level error
+ (the duplicate-id validator) has no ``loc`` but embeds the id in its
+ message, so scan for that too.
+ """
+ for err in errors:
+ loc = err.get("loc") or ()
+ if len(loc) >= 2 and loc[0] == "widgets" and isinstance(loc[1], int):
+ widgets = data.get("widgets") if isinstance(data, dict) else None
+ if isinstance(widgets, list) and 0 <= loc[1] < len(widgets):
+ widget = widgets[loc[1]]
+ if (
+ isinstance(widget, dict)
+ and isinstance(widget.get("id"), str)
+ and widget["id"]
+ ):
+ return widget["id"]
+ return f"widget #{loc[1]}"
+ for err in errors:
+ msg = err.get("msg") or ""
+ found = re.search(r"duplicate widget id: ['\"]([^'\"]+)['\"]", msg)
+ if found:
+ return found.group(1)
+ return None
+
+
def load_deck(path: Path) -> Deck:
- data = yaml.safe_load(path.read_text())
+ try:
+ data = yaml.safe_load(path.read_text())
+ except yaml.YAMLError as exc:
+ # A syntax error never reaches pydantic and — before issue #64 —
+ # never set ``current_error`` either, so a malformed file left the
+ # last-good deck on screen with no indication anything was wrong.
+ raise DeckLoadError(
+ f"invalid deck YAML at {path}:\n{exc}",
+ deck=path.stem,
+ ) from exc
try:
deck = Deck.model_validate(data)
except ValidationError as exc:
- raise SystemExit(f"invalid deck YAML at {path}:\n{exc}") from exc
+ raise DeckLoadError(
+ f"invalid deck YAML at {path}:\n{exc}",
+ deck=_identify_deck(data, path),
+ widget=_identify_widget(data, exc.errors()),
+ ) from exc
if deck.match:
deck.id = deck.match[0]
return deck
@@ -695,20 +784,17 @@ def _record(deck: Deck, path: Path) -> None:
for path in sorted(overlay_dir.glob("*.y*ml")):
if path.suffix not in {".yaml", ".yml"}:
continue
- try:
- deck = load_deck(path)
- except SystemExit as exc:
- raise SystemExit(f"{exc}") from None
+ # ``load_deck`` raises a ``DeckLoadError`` (a ``SystemExit``
+ # subclass) that already carries the structured deck/widget
+ # identity — let it propagate untouched.
+ deck = load_deck(path)
_record(deck, path)
overlay_ids = {deck.id for deck in decks if deck.id}
for path in sorted(decks_dir.glob("*.y*ml")):
if path.suffix not in {".yaml", ".yml"}:
continue
- try:
- deck = load_deck(path)
- except SystemExit as exc:
- raise SystemExit(f"{exc}") from None
+ deck = load_deck(path)
if deck.id and deck.id in overlay_ids:
log.info("deck %r overridden by overlay %s", deck.id, path)
continue
diff --git a/daemon/deckd/mpris.py b/daemon/deckd/mpris.py
index 6e0ccdb..542f20d 100644
--- a/daemon/deckd/mpris.py
+++ b/daemon/deckd/mpris.py
@@ -184,12 +184,36 @@ class NowPlaying(BaseModel):
empty_state: NowPlayingEmptyState = "show"
+class MprisCommandError(Exception):
+ """A media command couldn't be delivered (issue #64).
+
+ Raised by :meth:`DbusMprisBackend.send_command` when the target row
+ vanished (the player quit between the last snapshot and the tap) or
+ the player's D-Bus method rejected the call. The server turns it into
+ a ``media_error`` frame for the requesting session. Unknown *commands*
+ stay silent no-ops — a client never legitimately sends one, and there
+ is nothing useful to retry.
+ """
+
+ def __init__(self, row_id: str, message: str, *, retryable: bool = True) -> None:
+ super().__init__(message)
+ self.row_id = row_id
+ self.message = message
+ self.retryable = retryable
+
+
class MprisBackend(Protocol):
"""Backend seam for enumerating MPRIS rows and controlling them."""
def row_ids(self) -> list[str]:
...
+ def identity(self, row_id: str) -> str | None:
+ """The row's cached human-readable player name (MPRIS
+ ``Identity``), or ``None`` when unknown. Used to name the player
+ in a ``media_error`` frame (issue #64)."""
+ ...
+
async def read_state(self, row_id: str) -> MediaState | None:
...
@@ -346,6 +370,12 @@ def __init__(self, states: dict[str, MediaState] | None = None) -> None:
def row_ids(self) -> list[str]:
return list(self.states)
+ def identity(self, row_id: str) -> str | None:
+ """No live bus, so fall back to a seeded state's ``app_name``
+ (issue #64)."""
+ state = self.states.get(row_id)
+ return getattr(state, "app_name", None) if state is not None else None
+
async def read_state(self, row_id: str) -> MediaState | None:
state = self.states.get(row_id)
if state is None:
@@ -1017,22 +1047,32 @@ async def _read_identity(self, row_id: str) -> str | None:
self._identities[row_id] = identity
return identity
+ def identity(self, row_id: str) -> str | None:
+ """The row's cached MPRIS ``Identity`` (issue #64)."""
+ return self._identities.get(row_id)
+
async def send_command(self, row_id: str, command: str) -> None:
"""Dispatch a browser command to the corresponding MPRIS method.
- Unknown rows, unknown commands, and bus errors are all no-ops
- the server's pump catches and logs — the wire side never sees
- a failure, only a log line.
+ Unknown *commands* are no-ops (the wire literal makes one
+ impossible from a real client). A row that vanished before the
+ command arrived, or a player that rejected the method, raises
+ :class:`MprisCommandError` so the server can explain the failure
+ to the requesting session (issue #64).
+
+ Diagnostic event emission is unchanged: successful dispatches
+ still publish ``command``; failures publish ``dbus_error`` first.
"""
if self._bus is None or row_id not in self._owned_names:
- return
+ raise MprisCommandError(row_id, "player disappeared")
method = _COMMANDS.get(command)
if method is None:
return
+ from dbus_fast import MessageType
from dbus_fast.message import Message
try:
- await self._bus.call(
+ reply = await self._bus.call(
Message(
destination=f"{MPRIS_BUS_PREFIX}.{row_id}",
path=MPRIS_OBJECT_PATH,
@@ -1040,7 +1080,6 @@ async def send_command(self, row_id: str, command: str) -> None:
member=method,
)
)
- self._emit_diagnostic("command", row_id, {"command": command})
except Exception as exc:
log.warning(
"MPRIS %s.%s on %s failed: %s",
@@ -1054,6 +1093,19 @@ async def send_command(self, row_id: str, command: str) -> None:
row_id,
{"command": command, "error": repr(exc)},
)
+ raise MprisCommandError(row_id, "player rejected the command") from exc
+ if getattr(reply, "message_type", None) == MessageType.ERROR:
+ # A rejected method comes back as an ERROR *reply* (dbus-fast
+ # returns it rather than raising), so checking the reply type
+ # is the only way to notice it.
+ log.warning("MPRIS %s.%s on %s was rejected", PLAYER_INTERFACE, method, row_id)
+ self._emit_diagnostic(
+ "dbus_error",
+ row_id,
+ {"command": command, "error": "method rejected"},
+ )
+ raise MprisCommandError(row_id, "player rejected the command")
+ self._emit_diagnostic("command", row_id, {"command": command})
def _playback_to_playing(
diff --git a/daemon/deckd/platform.py b/daemon/deckd/platform.py
index 8055229..75ddd41 100644
--- a/daemon/deckd/platform.py
+++ b/daemon/deckd/platform.py
@@ -1366,8 +1366,19 @@ def _parse_single_bool_tuple(value: str) -> bool:
``ast.literal_eval`` can't parse, so normalise to Python's
capitalised literals first — same one-arg-tuple discipline as
:func:`_parse_single_string_tuple`.
+
+ A *variant*-typed property comes wrapped in angle brackets:
+ ``login1``'s ``LockedHint`` is type ``v``, so gdbus prints
+ ``(,)``. Strip those too, or the whole lock tick aborts on a
+ ``SyntaxError`` and no lock state is ever pushed.
"""
- normalised = value.replace("true", "True").replace("false", "False")
+ normalised = (
+ value.strip()
+ .replace("true", "True")
+ .replace("false", "False")
+ .replace("<", "")
+ .replace(">", "")
+ )
parsed = ast.literal_eval(normalised)
if not isinstance(parsed, tuple) or len(parsed) != 1 or not isinstance(parsed[0], bool):
raise RuntimeError(f"unexpected gdbus response: {value}")
diff --git a/daemon/deckd/protocol.py b/daemon/deckd/protocol.py
index 962378b..47be106 100644
--- a/daemon/deckd/protocol.py
+++ b/daemon/deckd/protocol.py
@@ -75,6 +75,14 @@ class DeckMessage(BaseModel):
# message in place of the widget grid; the daemon keeps the last-good
# decks live so a fix on disk restores service without a restart.
error: str | None = None
+ # Structured companions to ``error`` (issue #64): the human-readable
+ # deck name and the offending widget, when the daemon could identify
+ # them. The raw ``error`` text (a pydantic dump) is precise but
+ # machine-shaped; these let the client say *which deck* and *which
+ # widget* in plain language. Both are ``None`` for non-deck errors
+ # (e.g. a chrome-view not found) and when the location is unknown.
+ error_deck: str | None = None
+ error_widget: str | None = None
# The currently focused app's identity, populated when the daemon has a
# focus backend (never in headless mode). ``None`` before the first focus
# event arrives. The editor's new-deck creation flow (#104) uses this to
@@ -176,6 +184,38 @@ class ErrorMessage(BaseModel):
reason: str
+class MediaErrorMessage(BaseModel):
+ """Daemon -> client push: a media command the client issued failed
+ (issue #64).
+
+ The client's media surfaces (the VLC ``media`` widget and the
+ ``nowplaying`` rows) are fire-and-forget: a command to a player that
+ disappeared between the last snapshot and the tap, or one the player
+ rejected, previously vanished into a daemon log. This frame tells the
+ *requesting* session what happened so it can explain which player
+ was affected and offer a retry — it is not broadcast (the failure is
+ a consequence of one session's action).
+
+ ``id`` echoes the widget / row id the command targeted (the same id
+ the client sent), so the client can correlate and re-send. ``player``
+ is the human-readable player name (MPRIS ``Identity``, or the VLC
+ widget's label); the daemon falls back to the raw id when it can't
+ name the player, but the field stays nullable so an older server
+ leaves the client's own fallback reachable. ``retryable`` is ``True``
+ for every failure the daemon reports today (a vanished player or a
+ rejected call can both succeed on a retry) and defaults to ``True``;
+ it is reserved for a future failure a retry can't help.
+ """
+
+ model_config = ConfigDict(extra="forbid")
+
+ type: Literal["media_error"]
+ id: str
+ player: str | None = None
+ message: str
+ retryable: bool = True
+
+
class ChromeMediaMessage(BaseModel):
"""Daemon -> client push: the chrome media icon's passive
playback-state snapshot (issue #47).
@@ -341,7 +381,7 @@ class RunningWindowsMessage(BaseModel):
ServerMessage = Annotated[
- Union[DeckMessage, StateMessage, BrightnessMessage, WidgetUpdateMessage, MediaStateMessage, ChromeMediaMessage, EventMessage, MacroResultMessage, ConfirmRequestMessage, RunningWindowsMessage, ErrorMessage],
+ Union[DeckMessage, StateMessage, BrightnessMessage, WidgetUpdateMessage, MediaStateMessage, MediaErrorMessage, ChromeMediaMessage, EventMessage, MacroResultMessage, ConfirmRequestMessage, RunningWindowsMessage, ErrorMessage],
Field(discriminator="type"),
]
diff --git a/daemon/deckd/server.py b/daemon/deckd/server.py
index 4990a1e..62f8080 100644
--- a/daemon/deckd/server.py
+++ b/daemon/deckd/server.py
@@ -23,6 +23,7 @@
from .input import ScrollController, parse_key_combo, text_to_combos
from .decks import (
Deck,
+ DeckLoadError,
DeckStore,
icon_for_window,
label_for_window,
@@ -33,7 +34,13 @@
slugify_deck_id,
)
from .media import MediaManager, MediaState, effective_art_token
-from .mpris import ChromeMediaState, DbusMprisBackend, MprisBackend, connect_mpris_backend
+from .mpris import (
+ ChromeMediaState,
+ DbusMprisBackend,
+ MprisBackend,
+ MprisCommandError,
+ connect_mpris_backend,
+)
from .mpris_art import resolve_mpris_art_async
from .diagnostics import (
ActionRecord,
@@ -446,6 +453,12 @@ async def push_current(self) -> None:
deck = self.server.current_deck
app_id = self.server.current_app_id
error = self.server.current_error
+ # Structured companions to the global deck error (issue #64). They
+ # stay ``None`` unless the *global* on-disk deck error is what we
+ # end up sending — a pin, a resolved chrome view, or a view-not-found
+ # error carries no deck/widget identity of its own.
+ error_deck = self.server.current_error_deck
+ error_widget = self.server.current_error_widget
# Pinned demo session: render the named deck regardless of host focus.
# Re-resolved from the store each push, so a deck-file edit + reload
# refreshes the pinned view; if the deck was removed on reload, fall
@@ -455,6 +468,8 @@ async def push_current(self) -> None:
deck = self.server.decks[pin]
app_id = pin
error = None
+ error_deck = None
+ error_widget = None
# Chrome view pin (issue #50): render the selected view's deck
# regardless of host focus, with ``view`` set so the client knows to
# stay on this chrome view. The view id is the synthetic token the
@@ -472,10 +487,14 @@ async def push_current(self) -> None:
deck = self.server.decks[self.view]
app_id = self.view
error = None
+ error_deck = None
+ error_widget = None
view_id = self.view
else:
view_id = self.view
view_error = "view not found"
+ error_deck = None
+ error_widget = None
# Chrome app badge fields are relayed from the active deck even in
# the error path: the bottom chrome remains the chrome, and a branded
# badge is more useful than a bare match token while the user fixes
@@ -530,6 +549,8 @@ async def push_current(self) -> None:
is_default=is_default,
widgets=[],
error=error,
+ error_deck=error_deck,
+ error_widget=error_widget,
)
else:
widgets = [w.model_dump() for w in deck.widgets]
@@ -728,6 +749,12 @@ async def _timed_call(message: DbusMessage) -> DbusMessage:
# the shield (e.g. fully scripted kiosks).
self._allow_while_locked = allow_while_locked
self._current_error: str | None = None
+ # Structured companions to ``_current_error`` (issue #64): the
+ # best-effort human deck name and offending widget the error
+ # refers to, so the client can explain which deck/widget is
+ # broken without parsing the pydantic dump.
+ self._current_error_deck: str | None = None
+ self._current_error_widget: str | None = None
self._deckd_window_focused = False
# Stage 2 (#120 / #126): last pushed running-windows snapshot,
# used to dedupe identical pushes (the daemon-side debounce that
@@ -773,6 +800,14 @@ def current_app(self) -> "AppInfo | None":
def current_error(self) -> str | None:
return self._current_error
+ @property
+ def current_error_deck(self) -> str | None:
+ return self._current_error_deck
+
+ @property
+ def current_error_widget(self) -> str | None:
+ return self._current_error_widget
+
def reload_decks(self) -> None:
"""Re-read every deck YAML in ``decks_dir`` (and overlay_dir).
@@ -787,8 +822,22 @@ def reload_decks(self) -> None:
self.metrics.deck_reload_total += 1
try:
new_store = load_decks(self.decks_dir, self.overlay_dir)
+ except DeckLoadError as exc:
+ # Structured deck/widget identifiers (issue #64) ride the
+ # DeckMessage alongside the raw text so the client can name
+ # the broken deck and widget in plain language.
+ self._current_error = exc.message
+ self._current_error_deck = exc.deck
+ self._current_error_widget = exc.widget
+ self.metrics.deck_error_total += 1
+ log.error("deck reload failed (keeping last-good): %s", exc)
+ return
except SystemExit as exc:
+ # A non-file failure (e.g. the decks directory vanished):
+ # no deck/widget to name.
self._current_error = str(exc)
+ self._current_error_deck = None
+ self._current_error_widget = None
self.metrics.deck_error_total += 1
log.error("deck reload failed (keeping last-good): %s", exc)
return
@@ -804,6 +853,8 @@ def reload_decks(self) -> None:
# the resolved object identity so the wire stays truthful.
self._current_is_default = new_deck is self.decks.default()
self._current_error = None
+ self._current_error_deck = None
+ self._current_error_widget = None
self.metrics.deck_reload_ok_total += 1
# A deck reload can change which meter sources the active
# deck uses (a meter added in the new YAML, an old one
@@ -1208,6 +1259,34 @@ def _lock_gate_active(self) -> bool:
"""
return self._session_locked and not self._allow_while_locked
+ async def _send_media_error(
+ self,
+ session: Session,
+ *,
+ widget_id: str,
+ player: str,
+ message: str,
+ retryable: bool = True,
+ ) -> None:
+ """Report a failed media command to the requesting session (#64).
+
+ One place owns the wire shape so the MPRIS and VLC paths can't
+ drift; a send failure is swallowed like every other per-session
+ push (the connection is already going away).
+ """
+ try:
+ await session.send(
+ p.MediaErrorMessage(
+ type="media_error",
+ id=widget_id,
+ player=player,
+ message=message,
+ retryable=retryable,
+ )
+ )
+ except (ConnectionResetError, RuntimeError, ConnectionError):
+ pass
+
async def _refuse_locked(self, session: Session, what: str, primitive: str) -> None:
"""Record a lock-refused attempt and reply with an error frame.
@@ -2099,6 +2178,13 @@ async def _mpris_command(self, req: web.Request) -> web.Response:
return web.json_response(
{"ok": True, "row_id": row_id, "command": payload.command}
)
+ except MprisCommandError as exc:
+ # Issue #64: a vanished row is a 404, a rejected method a
+ # 502 — both structured, rather than the old silent 200.
+ self.metrics.record_mpris_command(payload.command, ok=False)
+ log.warning("MPRIS command %s on %s failed: %s", payload.command, row_id, exc)
+ status = 404 if exc.message == "player disappeared" else 502
+ return web.json_response({"ok": False, "error": exc.message}, status=status)
except Exception as exc:
self.metrics.record_mpris_command(payload.command, ok=False)
log.warning("MPRIS command %s on %s failed: %s", payload.command, row_id, exc)
@@ -2527,6 +2613,20 @@ async def _dispatch(self, session: Session, data: dict) -> None:
media_command.id.removeprefix("mpris."), media_command.command
)
self.metrics.record_mpris_command(media_command.command, ok=True)
+ except MprisCommandError as exc:
+ # Issue #64: tell the *requesting* session which player
+ # failed and whether a retry is worth offering. The row
+ # may already be gone, so name it from the cached
+ # Identity when we can, else fall back to the row id.
+ self.metrics.record_mpris_command(media_command.command, ok=False)
+ log.warning("MPRIS command %s failed: %s", media_command.command, exc)
+ await self._send_media_error(
+ session,
+ widget_id=media_command.id,
+ player=self.mpris.identity(exc.row_id) or exc.row_id,
+ message=exc.message,
+ retryable=exc.retryable,
+ )
except Exception as exc:
self.metrics.record_mpris_command(media_command.command, ok=False)
log.warning("MPRIS command %s failed: %s", media_command.command, exc)
@@ -2547,7 +2647,15 @@ async def _dispatch(self, session: Session, data: dict) -> None:
except Exception as exc:
# A failed/unsupported media command must not tear down the
# websocket session (which would reset the client UI).
+ # Issue #64: report it to the requesting session instead of
+ # leaving the tap silently dead.
log.warning("media command %s failed: %s", media_command.command, exc)
+ await self._send_media_error(
+ session,
+ widget_id=media_command.id,
+ player=widget.label or widget.id,
+ message="player rejected the command",
+ )
return
if msg_type == "select_view":
select_view = p.SelectViewMessage.model_validate(data)
diff --git a/docs/GUIDE.md b/docs/GUIDE.md
index 306f631..36bc278 100644
--- a/docs/GUIDE.md
+++ b/docs/GUIDE.md
@@ -272,6 +272,17 @@ Every deck renders inside a persistent **chrome** shell that the daemon does not
Widgets in a deck's `widgets:` list are an **ordered list** that reflows against the viewport width (ADR-0010). There are no grid coordinates. The client packs widgets left-to-right and wraps down, computing the column count from the available width against a client-side cell-size band. A widget may carry a `size: [w, h]` span (default `[1, 1]`) for non-uniform cells; the list order is the only positional input. Portrait just fits fewer columns — no transpose, no orientation conventions.
+### Feedback and recovery
+
+Every non-happy state explains itself in plain language rather than leaving a stale or dead surface:
+
+- **Reconnecting / disconnected** — when the socket drops, an overlay sits over the last-rendered deck naming the attempt count and the last error (e.g. *Attempt 3 — daemon not reachable*). While the client is retrying it offers **Cancel** to pause the auto-retry loop; once paused (or when the socket is closed) it offers **Retry** to reconnect immediately. The bottom-strip connection dot keeps showing live / reconnecting / disconnected alongside it.
+- **Screen locked / asleep** — the full-surface lock takeover names the cause (*Screensaver or lock screen active on ‹host›*) and states that input is held until you unlock. The soft "asleep" banner shown for a blank that hasn't locked says *suspend or display blank*, and a press wakes the machine.
+- **Deck errors** — a deck that fails to load (bad YAML or a schema violation) replaces the grid with the pydantic detail plus a plain-language line naming the deck and the offending widget (`Couldn't load the "firefox" deck. Widget "back" is invalid.`) and a **Return to focused app** button that clears a pinned chrome view. A chrome view that no longer resolves shows *That view isn't available on this daemon.* with the same button.
+- **Media errors** — a media command the daemon couldn't deliver (player gone, method rejected) surfaces a non-blocking toast naming the player (`VLC media player — player disappeared`) with **Retry** (re-sends the last command for that player) and **Dismiss**.
+
+Each of these is announced through the client's `aria-live` region, and every animation is suppressed under `prefers-reduced-motion: reduce` or the in-app *Reduce motion* setting.
+
### Manual control mode
Tap the `manual control` button in the bottom chrome and the deck area is replaced by a single combined surface: a **trackpad** for cursor movement and a **keyboard passthrough** for typing into the currently-focused desktop app, both live at the same time. No mode switching. The trackpad handles pointing and clicking, and a small **strip at the top** of the surface hosts the few keys mobile IMEs can't produce (Esc, Tab, arrows) plus a keyboard-icon toggle that raises the phone's soft keyboard when you want to type. When the IME is open you can still drag on the trackpad area to move the cursor — the two coexist.
@@ -409,7 +420,7 @@ The client is usable end-to-end without a mouse (issues [#60](https://github.com
**Focus restoration** — opening a chrome view (settings, trackpad, now playing) pushes focus into the first interactive element of that view; closing it (via `Escape` or the same button) hands focus back to the chrome button that opened it. The password gate also restores focus to the surface after a successful submit, so a keyboard user can Tab into the deck without clicking anywhere.
-**OS-level preferences** — the theme respects `prefers-contrast: more` (thicker focus ring, higher-contrast cell borders, white halo on the connection dot) and `prefers-reduced-motion: reduce` (the connection-state pulse and the media-icon playback dot stop animating; press feedback loses its scale-down but keeps the static brightness shift). Status (connection state, playback state) is conveyed by **icon + text + colour** so it doesn't depend on colour alone: the connection indicator has a visible "live" / "reconnecting" / "disconnected" / "locked" label, and the media icon carries a screen-reader-only "now playing" / "idle" string alongside the pulsing green dot.
+**OS-level preferences** — the theme respects `prefers-contrast: more` (thicker focus ring, higher-contrast cell borders, white halo on the connection dot) and `prefers-reduced-motion: reduce` (the connection-state pulse and the media-icon playback dot stop animating; press feedback loses its scale-down but keeps the static brightness shift). Status (connection state, playback state) is conveyed by **icon + text + colour** so it doesn't depend on colour alone: the connection indicator has a visible "live" / "reconnecting" / "disconnected" / "sign-in needed" label, and the media icon carries a screen-reader-only "now playing" / "idle" string alongside the pulsing green dot.
**Typography** — labels scale with the browser zoom *and* the existing Text-size slider; cell sizes use `clamp(min, vw, max)` so they grow with the viewport. The Content-size and Bottom-bar sliders affect icon + chrome sizes without clipping adjacent content.
diff --git a/tests/test_decks.py b/tests/test_decks.py
index 1e43333..3af1275 100644
--- a/tests/test_decks.py
+++ b/tests/test_decks.py
@@ -12,7 +12,7 @@
import pytest
-from deckd.decks import Deck, Widget, load_decks, resolve_deck
+from deckd.decks import Deck, DeckLoadError, Widget, load_decks, resolve_deck
from deckd.platform import AppInfo
@@ -654,6 +654,86 @@ def test_action_terminal_string_rejected_with_guidance(tmp_path: Path) -> None:
assert 'shell: "tilix"' in msg
+# ---------------------------------------------------------------------------
+# Structured load errors (issue #64)
+#
+# The raw pydantic dump is precise but machine-shaped. The client needs the
+# deck name and the offending widget in plain language so its error surface
+# can explain what broke instead of pasting a traceback.
+# ---------------------------------------------------------------------------
+
+
+def test_deck_load_error_names_deck_and_widget(tmp_path: Path) -> None:
+ """A widget field error surfaces the deck name and the widget id."""
+ body = """
+match:
+ - firefox
+widgets:
+ - id: back
+ kind: button
+ action:
+ key: "alt+Left"
+ - id: broken
+ kind: media
+ controls: [bogus-control]
+"""
+ _write(tmp_path, "firefox.yaml", body)
+ with pytest.raises(DeckLoadError) as exc:
+ load_decks(tmp_path)
+ err = exc.value
+ assert err.deck == "firefox"
+ assert err.widget == "broken"
+ assert "controls" in str(err)
+
+
+def test_deck_load_error_names_deck_from_display_name(tmp_path: Path) -> None:
+ """When the authored ``id`` is absent, the display name is used."""
+ body = """
+match:
+ - firefox
+display_name: Firefox
+widgets:
+ - id: broken
+ kind: button
+ action:
+ nope: true
+"""
+ _write(tmp_path, "firefox.yaml", body)
+ with pytest.raises(DeckLoadError) as exc:
+ load_decks(tmp_path)
+ assert exc.value.deck == "Firefox"
+ assert exc.value.widget == "broken"
+
+
+def test_deck_load_error_finds_duplicate_widget_id(tmp_path: Path) -> None:
+ """The model-level duplicate-id error has no ``loc``; the id still
+ comes out so the UI can name the culprit."""
+ body = """
+match:
+ - default
+widgets:
+ - id: back
+ kind: button
+ - id: back
+ kind: button
+"""
+ _write(tmp_path, "default.yaml", body)
+ with pytest.raises(DeckLoadError) as exc:
+ load_decks(tmp_path)
+ assert exc.value.widget == "back"
+
+
+def test_yaml_syntax_error_is_a_structured_load_error(tmp_path: Path) -> None:
+ """A malformed YAML document used to escape as a bare yaml error and
+ silently keep the last-good deck live (issue #64). It now surfaces as
+ a ``DeckLoadError`` so the client shows something."""
+ _write(tmp_path, "default.yaml", "match: [default\nwidgets: []\n")
+ with pytest.raises(DeckLoadError) as exc:
+ load_decks(tmp_path)
+ assert exc.value.deck == "default"
+ assert exc.value.widget is None
+
+
# ---------------------------------------------------------------------------
# Platform overlay
#
diff --git a/tests/test_feedback.py b/tests/test_feedback.py
new file mode 100644
index 0000000..489a48f
--- /dev/null
+++ b/tests/test_feedback.py
@@ -0,0 +1,223 @@
+"""Issue #64: reconnect, locked, and error feedback that explains and recovers.
+
+The client-facing half of these states lives in the React app; these tests
+cover the daemon seam that has to *supply the facts* the client explains:
+
+* a failed deck reload now carries the deck name and offending widget so
+ the error surface can name them (instead of pasting a pydantic dump);
+* a failed media command — MPRIS player gone / method rejected, or the VLC
+ HTTP call failing — is reported to the requesting session as a
+ ``media_error`` frame naming the player, rather than vanishing into a log.
+"""
+from __future__ import annotations
+
+import asyncio
+import json
+from pathlib import Path
+
+import aiohttp
+import websockets
+from aiohttp.test_utils import TestServer
+
+from conftest import make_test_server
+from deckd.media import MediaState
+from deckd.mpris import FakeMprisBackend, MprisCommandError
+
+
+async def _recv_type(ws, wanted: str, timeout: float = 2.0) -> dict:
+ """Read frames until one of ``wanted`` type arrives (or time out)."""
+ while True:
+ frame = json.loads(await asyncio.wait_for(ws.recv(), timeout))
+ if frame.get("type") == wanted:
+ return frame
+
+
+# ---------------------------------------------------------------------------
+# Deck reload error: structured deck / widget names on the wire
+# ---------------------------------------------------------------------------
+
+
+_GOOD = """
+match: [default]
+widgets:
+ - id: home
+ kind: button
+"""
+
+_BAD = """
+match: [default]
+widgets:
+ - id: home
+ kind: button
+ action:
+ nope: true
+"""
+
+
+async def test_deck_reload_error_carries_deck_and_widget(tmp_path: Path) -> None:
+ (tmp_path / "default.yaml").write_text(_GOOD)
+ server, *_ = make_test_server(decks_dir=tmp_path)
+ test_server = TestServer(server.app, host="127.0.0.1")
+ await test_server.start_server()
+ try:
+ async with websockets.connect(f"ws://127.0.0.1:{test_server.port}/ws") as ws:
+ first = json.loads(await asyncio.wait_for(ws.recv(), 2))
+ assert first["type"] == "deck"
+ assert first["error"] is None
+
+ # Break the file on disk and reload; the daemon keeps the
+ # last-good deck but flags the error on the next push.
+ (tmp_path / "default.yaml").write_text(_BAD)
+ await server.reload_and_push()
+
+ err = await _recv_type(ws, "deck")
+ assert err["error"], "raw error text still rides for diagnostics"
+ assert err["error_deck"] == "default"
+ assert err["error_widget"] == "home"
+ assert err["widgets"] == []
+ finally:
+ await server.stop()
+ await test_server.close()
+
+
+# ---------------------------------------------------------------------------
+# Media errors: MPRIS player gone / command rejected
+# ---------------------------------------------------------------------------
+
+
+class _RaisingMpris(FakeMprisBackend):
+ """FakeMprisBackend whose commands always fail with a given error."""
+
+ def __init__(self, states: dict[str, MediaState], error: MprisCommandError) -> None:
+ super().__init__(states)
+ self._error = error
+
+ async def send_command(self, row_id: str, command: str) -> None:
+ raise self._error
+
+ def identity(self, row_id: str) -> str | None:
+ return "VLC media player"
+
+
+async def test_mpris_media_error_is_reported_to_requester(tmp_path: Path) -> None:
+ (tmp_path / "default.yaml").write_text(
+ """
+match: [default]
+widgets:
+ - id: browser
+ kind: nowplaying
+ size: [4, 2]
+"""
+ )
+ backend = _RaisingMpris(
+ {"vlc": MediaState(available=True, stale=False, playing=True, title="VLC")},
+ MprisCommandError("vlc", "player disappeared"),
+ )
+ server, *_ = make_test_server(decks_dir=tmp_path, mpris_backend=backend)
+ test_server = TestServer(server.app, host="127.0.0.1")
+ await test_server.start_server()
+ try:
+ async with websockets.connect(f"ws://127.0.0.1:{test_server.port}/ws") as ws:
+ await _recv_type(ws, "deck")
+ await ws.send(
+ json.dumps(
+ {"type": "media_command", "id": "mpris.vlc", "command": "next"}
+ )
+ )
+ err = await _recv_type(ws, "media_error")
+ assert err["id"] == "mpris.vlc"
+ assert err["player"] == "VLC media player"
+ assert err["retryable"] is True
+ assert "player disappeared" in err["message"]
+ finally:
+ await server.stop()
+ await test_server.close()
+
+
+async def test_mpris_http_command_reports_missing_player(tmp_path: Path) -> None:
+ """The authenticated HTTP command endpoint mirrors the WS feedback:
+ a vanished row is a structured 404, not a silent 200 (issue #64)."""
+ (tmp_path / "default.yaml").write_text(
+ """
+match: [default]
+widgets:
+ - id: browser
+ kind: nowplaying
+ size: [4, 2]
+"""
+ )
+ backend = _RaisingMpris(
+ {"vlc": MediaState(available=True, stale=False, playing=True, title="VLC")},
+ MprisCommandError("vlc", "player disappeared"),
+ )
+ server, *_ = make_test_server(decks_dir=tmp_path, mpris_backend=backend)
+ test_server = TestServer(server.app, host="127.0.0.1")
+ await test_server.start_server()
+ try:
+ async with aiohttp.ClientSession() as client:
+ resp = await client.post(
+ f"http://127.0.0.1:{test_server.port}/mpris/vlc/command",
+ json={"command": "next"},
+ )
+ assert resp.status == 404
+ body = await resp.json()
+ assert body == {"ok": False, "error": "player disappeared"}
+ finally:
+ await server.stop()
+ await test_server.close()
+
+
+class _FailingMediaManager:
+ def stop(self) -> None:
+ pass
+
+ async def read(self, key: str, *, host: str, port: int, password_ref: str | None) -> MediaState:
+ return MediaState(available=True, stale=False, playing=True)
+
+ async def command(
+ self,
+ key: str,
+ command: str,
+ value: float,
+ *,
+ host: str,
+ port: int,
+ password_ref: str | None,
+ ) -> None:
+ raise RuntimeError("connection refused")
+
+
+async def test_vlc_media_error_is_reported_to_requester(tmp_path: Path) -> None:
+ (tmp_path / "default.yaml").write_text(
+ """
+match: [default]
+widgets:
+ - id: media
+ kind: media
+ label: Living Room VLC
+ size: [4, 2]
+ media_http:
+ host: media.local
+ port: 9090
+"""
+ )
+ server, *_ = make_test_server(
+ decks_dir=tmp_path, media_manager=_FailingMediaManager()
+ )
+ test_server = TestServer(server.app, host="127.0.0.1")
+ await test_server.start_server()
+ try:
+ async with websockets.connect(f"ws://127.0.0.1:{test_server.port}/ws") as ws:
+ await _recv_type(ws, "deck")
+ await ws.send(
+ json.dumps(
+ {"type": "media_command", "id": "media", "command": "volume", "value": 55}
+ )
+ )
+ err = await _recv_type(ws, "media_error")
+ assert err["id"] == "media"
+ assert err["player"] == "Living Room VLC"
+ assert err["retryable"] is True
+ finally:
+ await server.stop()
+ await test_server.close()
diff --git a/tests/test_mpris_dbus.py b/tests/test_mpris_dbus.py
index ef09a6b..b558a6e 100644
--- a/tests/test_mpris_dbus.py
+++ b/tests/test_mpris_dbus.py
@@ -37,6 +37,7 @@
FakeMprisBackend,
MPRIS_BUS_PREFIX,
MPRIS_OBJECT_PATH,
+ MprisCommandError,
PLAYER_INTERFACE,
PROPERTIES_INTERFACE,
ROOT_INTERFACE,
@@ -418,16 +419,45 @@ async def test_send_command_unknown_command_is_a_noop() -> None:
@pytest.mark.asyncio
-async def test_send_command_unknown_row_is_a_noop() -> None:
+async def test_send_command_unknown_row_raises_for_feedback() -> None:
+ """A row that vanished is reported, not silently dropped (issue #64)."""
bus = FakeDbusBus()
backend = DbusMprisBackend()
backend._bus = bus
backend._owned_names = {"vlc"}
- await backend.send_command("nonexistent", "next")
+ with pytest.raises(MprisCommandError) as excinfo:
+ await backend.send_command("nonexistent", "next")
+ assert excinfo.value.row_id == "nonexistent"
+ assert excinfo.value.retryable is True
assert bus.calls == []
+@pytest.mark.asyncio
+async def test_send_command_bus_error_raises_and_still_reports_diagnostic() -> None:
+ """A rejected D-Bus call raises for feedback (issue #64) while keeping
+ the diagnostic event the ring buffer already relied on."""
+ bus = FakeDbusBus()
+ bus.player_methods = lambda _dest, _member: False # ERROR reply
+ backend = DbusMprisBackend()
+ backend._bus = bus
+ backend._owned_names = {"vlc"}
+ seen: list[tuple[str, str | None, dict[str, Any]]] = []
+ backend.set_diagnostic_listener(lambda kind, row, data: seen.append((kind, row, data)))
+
+ with pytest.raises(MprisCommandError):
+ await backend.send_command("vlc", "next")
+ assert [kind for kind, _, _ in seen] == ["dbus_error"]
+
+
+@pytest.mark.asyncio
+async def test_identity_returns_cached_mpris_identity() -> None:
+ backend = DbusMprisBackend()
+ backend._identities["vlc"] = "VLC media player"
+ assert backend.identity("vlc") == "VLC media player"
+ assert backend.identity("gone") is None
+
+
# ---------------------------------------------------------------------------
# Slice 4 — NameOwnerChanged subscribes and reconciles row_ids
# ---------------------------------------------------------------------------
diff --git a/tests/test_session_lock.py b/tests/test_session_lock.py
index 90a6038..340b190 100644
--- a/tests/test_session_lock.py
+++ b/tests/test_session_lock.py
@@ -86,6 +86,17 @@ async def test_gnome_locked(fake_gdbus) -> None:
assert state.blanked is True
+async def test_login_locked_hint_parses_real_variant_form(fake_gdbus) -> None:
+ """``login1``'s ``LockedHint`` is a GVariant of type ``v``, and gdbus
+ prints it as ``(,)`` — angle brackets and all. The parser must
+ strip them or every lock tick raises SyntaxError and nothing is ever
+ pushed (regression: issue #160, found reproducing #64)."""
+ fake_gdbus.scripted = {"GetActive": "(,)", "LockedHint": "(,)"}
+ state = await fake_gdbus._session_state_once()
+ assert state.locked is True
+ assert state.blanked is True
+
+
async def test_gnome_login1_failure_degrades_to_unlocked(fake_gdbus) -> None:
"""login1 unreachable → the lock half stays false, never an error."""
fake_gdbus.scripted = {"GetActive": "(false,)"}