Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,17 @@ _Avoid_: host, listen address, exposed interface
**Pairing URL**:
The single `http://<bind-host>:<port>/` URL a phone types into its browser to reach the daemon. Surfaced as the `url` field on `/health` and `/diag`, and prepended above the JSON body of `deckctl status`. Prefers IPv4 over IPv6 so a phone on a typical home LAN doesn't get a `http://[::1]:.../` link it can't resolve. The password gate still has to be cleared by every non-localhost client; the pairing URL is a convenience, not a bypass.
_Avoid_: connection URL, daemon URL

### Session screen state (issue #160)

**Screen locked**:
The desktop session requires credentials (GNOME lock, `loginctl lock-session` — detected via the `login1` session's `LockedHint`). The client renders the full lock takeover over focus-targeting surfaces, the app badge reads "Screen locked", and the daemon **refuses** presses, key/type injection, jog/trackpad input, and window raising (with an `error` frame, reason `screen_locked`, recorded as `lock_dropped`). Now playing, settings, and the layout editor keep working. Not the same state as the deckd password gate.
_Avoid_: sign-in, login gate, password screen

**Sign-in needed**:
The deckd *client-auth* state: an `unauthorized` socket — the user has to type the deckd shared password. The connection status label and aria-live copy. Reserved wording: "locked" belongs to the session state below, never to the password gate.
_Avoid_: locked, screen locked, auth screen

**Screen blanked**:
The session isn't on the screen (GNOME screen blank). Detection and *policy* are deliberately separate: blanking alone (`org.gnome.ScreenSaver.GetActive`) with Automatic Screen Lock off keeps input **enabled** — a press wakes the machine, exactly like poking a real keyboard. Only the soft "Screen asleep — press anything to wake" banner shows. Blanked **and** locked is the lock takeover.
_Avoid_: screen off, standby, sleep
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ Pre-alpha, but usable day-to-day. Here's what deckd can do today and what's stil
- [x] **App badge** — the focused app's name, icon, and accent color show in the bottom bar so you can tell at a glance what you're controlling.
- [x] **Chrome media indicator** — the media icon sprouts a pulsing green dot whenever a media player is playing (passive playback indicator), independent of the browser view.
- [x] **Now playing** — control any supported media player (Spotify, Firefox, VLC, etc.) from a dedicated chrome view, with album art, per-player transport controls, and now-playing metadata.
- [x] **Session lock awareness** — when the desktop session locks, the phone swaps to a "Screen locked" takeover and the daemon refuses presses / injection / trackpad / window raising (`--allow-while-locked` opts out); a plain screen blank keeps input live so a press wakes the machine.
- [x] **Running programs list** — tap a layout-grid icon in the bottom chrome to open a list of every open window on the host, labeled by the layout the window would match. Tap a row to raise (focus) that window and close the list. Enumeration and raise are GNOME-only today (via the focus extension); other backends show the list's "unsupported on this platform" empty state.
- [x] **VLC media widgets** — full VLC control surface with play/pause, seek, volume, album art. Configurable art sources (VLC embedded art + iTunes fallback).
- [x] **Live sensor widgets** — meter and stats widgets pushed to the client in real time (CPU %, memory %, etc.), bound to daemon-side sensor sources.
Expand Down
99 changes: 96 additions & 3 deletions client/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, ServerLayout } from "./protocol";
import type { ClientMessage, ServerChromeMedia, ServerLayout, 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
Expand All @@ -34,16 +34,21 @@ const onLayout = vi.fn<(m: ServerLayout) => void>();
let mockStatus: "connecting" | "open" | "closed" | "unauthorized" = "open";
const authenticate = vi.fn();
const deauthenticate = vi.fn();
let sessionStateHandler: ((m: ServerState) => void) | null = null;
vi.mock("./socket", () => ({
useDeckdSocket: (
layoutCb: (m: ServerLayout) => void,
_widgetUpdate: unknown,
_mediaState: unknown,
chromeMediaCb: ((m: ServerChromeMedia) => void) | undefined,
_confirmRequestCb: unknown,
_runningWindowsCb: unknown,
sessionStateCb: ((m: ServerState) => void) | undefined,
_options: unknown,
) => {
onLayout.mockImplementation(layoutCb);
chromeMediaHandler = chromeMediaCb ?? null;
sessionStateHandler = sessionStateCb ?? null;
return {
get status() {
return mockStatus;
Expand Down Expand Up @@ -914,11 +919,13 @@ describe("App — aria-live announcements", () => {
expect(screen.getByRole("status").textContent).toBe("Reconnecting");
});

it("announces a locked status change", () => {
it("announces a sign-in-needed status change (issue #160 rename)", () => {
const { rerender } = render(<App />);
mockStatus = "unauthorized";
rerender(<App />);
expect(screen.getByRole("status").textContent).toBe("Locked");
// Issue #160: "locked" now belongs to the desktop session; the
// deckd-password gate is "sign-in needed".
expect(screen.getByRole("status").textContent).toBe("Sign-in needed");
});

it("announces connected when returning from closed", () => {
Expand Down Expand Up @@ -1017,3 +1024,89 @@ describe("App — accessibility CSS classes", () => {
expect(app?.classList.contains("a11y-reduce-motion")).toBe(true);
});
});

/* -------------------------------------------------------------------------
* Session lock / blank awareness (issue #160): the ``state`` frames drive
* the takeover; presses stay enabled under a blank (wake from the phone).
* --------------------------------------------------------------------- */

describe("App — session lock takeover", () => {
afterEach(cleanup);
beforeEach(() => {
send.mockReset();
sessionStateHandler = null;
mockStatus = "open";
window.history.replaceState(null, "", "/?demo=default");
});

it("replaces the grid with the lock takeover when locked", () => {
render(<App />);
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();
});

it("names the host in the lock copy", () => {
render(<App />);
act(() => sessionStateHandler?.({ type: "state", locked: true, blanked: false }));
expect(screen.getByText(/localhost/)).toBeTruthy();
});

it("keeps the connection dot's 'live' status while locked", () => {
render(<App />);
act(() => sessionStateHandler?.({ type: "state", locked: true, blanked: false }));
expect(screen.getByText("live")).toBeTruthy();
});

it("reads 'Screen locked' on the app badge while locked", () => {
render(<App />);
act(() => sessionStateHandler?.({ type: "state", locked: true, blanked: false }));
const badge = document.querySelector(".app-badge-name, .app-name");
expect(badge?.textContent).toBe("Screen locked");
});

it("disables the running-programs and trackpad chrome buttons while locked", () => {
render(<App />);
act(() => sessionStateHandler?.({ type: "state", locked: true, blanked: false }));
const windowsBtn = screen.getByRole("button", { name: /running programs/i });
const trackpadBtn = screen.getByRole("button", { name: /manual control/i });
expect(windowsBtn.getAttribute("disabled")).toBe("");
expect(trackpadBtn.getAttribute("disabled")).toBe("");
});

it("announces lock restoration and restores the grid on unlock", () => {
render(<App />);
act(() => sessionStateHandler?.({ type: "state", locked: true, blanked: false }));
expect(screen.getAllByText("Screen locked").length).toBeGreaterThan(0);
act(() => sessionStateHandler?.({ type: "state", locked: false, blanked: false }));
expect(screen.queryByText("Screen locked")).toBeNull();
expect(screen.getByRole("button", { name: /open/i })).toBeTruthy();
});

it("shows the asleep banner (input stays enabled) on blank-without-lock", () => {
render(<App />);
act(() => sessionStateHandler?.({ type: "state", locked: false, blanked: true }));
expect(screen.getByText(/Screen asleep — press anything to wake/)).toBeTruthy();
// Grid buttons are still rendered (they can press-awake the host).
expect(screen.getByRole("button", { name: /open/i })).toBeTruthy();
});

it("appends the lock state into the aria-live stream", () => {
render(<App />);
act(() => sessionStateHandler?.({ type: "state", locked: true, blanked: false }));
// The sr-only live region shares its text with the visible lock card;
// assert the announcement landed through the second status region.
expect(screen.getAllByText("Screen locked").length).toBeGreaterThan(0);
});

it('renders the running-windows lock empty state naming the host', () => {
render(<App />);
act(() => sessionStateHandler?.({ type: "state", locked: true, blanked: false }));
// The windows view itself is replaced by the takeover; drive the
// component contract through its props instead (lockedHost).
// Covered indirectly here by asserting the disabled button; the list
// slot itself is covered in RunningWindowsList.test.tsx.
expect(screen.getByRole("button", { name: /running programs/i }).getAttribute("disabled")).toBe("");
});
});
Loading
Loading