diff --git a/CONTEXT.md b/CONTEXT.md index ab543c5..23c8cec 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -76,3 +76,17 @@ _Avoid_: host, listen address, exposed interface **Pairing URL**: The single `http://:/` 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 diff --git a/README.md b/README.md index e966174..d459210 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/client/src/App.test.tsx b/client/src/App.test.tsx index 412059e..98d5931 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, 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 @@ -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; @@ -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(); mockStatus = "unauthorized"; rerender(); - 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", () => { @@ -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(); + 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(); + act(() => sessionStateHandler?.({ type: "state", locked: true, blanked: false })); + expect(screen.getByText(/localhost/)).toBeTruthy(); + }); + + it("keeps the connection dot's 'live' status while locked", () => { + render(); + act(() => sessionStateHandler?.({ type: "state", locked: true, blanked: false })); + expect(screen.getByText("live")).toBeTruthy(); + }); + + it("reads 'Screen locked' on the app badge while locked", () => { + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(""); + }); +}); diff --git a/client/src/App.tsx b/client/src/App.tsx index c5eebd0..e12492b 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Pencil as PencilIcon, Settings as SettingsIcon, Globe as GlobeIcon } from "lucide-react"; +import { Lock as LockIcon, Moon as MoonIcon, Pencil as PencilIcon, Settings as SettingsIcon, Globe as GlobeIcon } from "lucide-react"; import { LayoutGrid as LayoutGridIcon, Music as MusicIcon, PointerIcon } from "lucide-react"; import { useDeckdSocket } from "./socket"; import { ButtonGrid } from "./ButtonGrid"; @@ -38,6 +38,7 @@ import type { ServerChromeMedia, ServerLayout, ServerRunningWindows, + ServerState, WindowListEntry, } from "./protocol"; import { EDITOR_VIEW_ID, MPRIS_VIEW_ID, WINDOWS_VIEW_ID } from "./protocol"; @@ -62,9 +63,23 @@ const STATUS_LABEL: Record = { open: "live", connecting: "reconnecting", closed: "disconnected", - unauthorized: "locked", + // Issue #160 naming collision fix: "locked" now belongs to the + // *desktop session* state ("Screen locked"). The deckd-password gate + // is "sign-in needed". + unauthorized: "sign-in needed", }; +/** The host the socket is driving. Matters under lock (issue #160) when + * one phone drives two machines: the takeover names which screen needs + * unlocking. The page host is the daemon (served directly or proxied). */ +const HOSTNAME = (() => { + try { + return window.location.hostname || "your computer"; + } catch { + return "your computer"; + } +})(); + export function App() { // Demo mode (``?demo=``): render a fixture layout with the socket // disabled, so the client can be viewed without a daemon. Null in normal @@ -177,6 +192,14 @@ export function App() { // platform" empty state in the meantime (issue #120 decision 8). const [runningWindows, setRunningWindows] = useState(undefined); const onRunningWindows = useCallback((m: ServerRunningWindows) => setRunningWindows(m.windows), []); + // Two-state session screen awareness (issue #160). ``null`` until the + // first ``state`` frame arrives — both halves read false, which is + // exactly what a daemon without lock detection ever pushes. ``locked`` + // drives the lock takeover + daemon-side gated refusals; ``blanked`` + // (without lock) drives only the soft "asleep" banner and keeps + // presses enabled so the first tap wakes the machine. + const [sessionState, setSessionState] = useState(null); + const onSessionState = useCallback((m: ServerState) => setSessionState(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 = demoLayout !== null; @@ -213,6 +236,7 @@ export function App() { onChromeMedia, onConfirmRequest, onRunningWindows, + onSessionState, { enabled: !demoLayout && !isPlayground }, ); const playgroundSocket = usePlaygroundDaemon( @@ -226,6 +250,11 @@ export function App() { ); const { status, send, authenticate, deauthenticate, hasPassword } = 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. + const screenLocked = sessionState?.locked ?? false; + const screenBlanked = !screenLocked && (sessionState?.blanked ?? false); // Look the pressed widget up in the active layout so the modal // can show its label / icon (the daemon doesn't send command text // on the wire). If the layout has rotated away between the press @@ -446,6 +475,10 @@ export function App() { return; } if (e.altKey || e.ctrlKey || e.metaKey) return; + // Issue #160: while locked, the surfaces these two shortcuts + // open (trackpad manual injection, running-windows raise) are + // gated — don't invite the user into a dead view. + if (screenLocked && (e.key === "1" || e.key === "5")) return; if (e.key === "1") { e.preventDefault(); viewOriginRef.current = trackpadBtnRef.current; @@ -475,7 +508,7 @@ export function App() { }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [navigate, view, openTrackpad, openSettings, toggleNowPlaying, toggleEditor, toggleWindows, send, status]); + }, [navigate, view, openTrackpad, openSettings, toggleNowPlaying, toggleEditor, toggleWindows, send, status, screenLocked]); const jogstripEnabled = layout?.jogstrip_enabled ?? true; const statusLabel = STATUS_LABEL[status]; @@ -524,8 +557,19 @@ export function App() { if (status === "open") setLiveText("Connected"); else if (status === "connecting") setLiveText("Reconnecting"); else if (status === "closed") setLiveText("Disconnected"); - else if (status === "unauthorized") setLiveText("Locked"); + else if (status === "unauthorized") setLiveText("Sign-in needed"); }, [status]); + // Gesture... issue #160: announce lock / wake transitions so a screen + // reader learns the takeover replaced the grid and why. + const prevSessionState = useRef(sessionState); + useEffect(() => { + if (prevSessionState.current === sessionState) return; + prevSessionState.current = sessionState; + // eslint-disable-next-line react-hooks/set-state-in-effect + if (sessionState?.locked === true) setLiveText("Screen locked"); + else if (sessionState?.blanked === true) setLiveText("Screen asleep"); + else if (sessionState != null) setLiveText("Home"); + }, [sessionState]); useEffect(() => { if (prevLayout.current === layout) return; prevLayout.current = layout; @@ -543,7 +587,11 @@ export function App() { // absent theme leaves the badge on the default chrome treatment. A // layout declaring neither an icon nor a theme renders the chrome // unchanged (bold text, no pill) so existing layouts look identical. - const appName = layout + // Issue #160: while the session is locked the badge reads "Screen + // locked" in place of the app name. + const appName = screenLocked + ? "Screen locked" + : layout ? (layout.display_name?.trim() || layout.app) + programSuffix : "deckd"; const appTheme = layout?.theme?.trim() || null; @@ -581,6 +629,19 @@ export function App() { ); } + // Issue #160: per-capability gate, not a global switch. While the + // session is locked, the surfaces that target the focused window + // (grid, jogstrip, trackpad) are replaced by the lock takeover; + // now playing, settings, and the layout editor keep working (MPRIS + // is alive behind the shield, settings are device-local, the editor + // writes YAML to disk). The running-windows view is NOT in the + // takeover set: its chrome button is disabled while locked, but a + // session already on the view renders the list's lock-specific + // empty state rather than dead rows (issue #160). + const lockBlockedView = view === "layout" || view === "trackpad"; + const renderLockTakeover = screenLocked && lockBlockedView; + const renderBlankBanner = screenBlanked && !screenLocked && view === "layout"; + return ( <> {liveText} @@ -602,7 +663,23 @@ export function App() { } >

{headingText}

- {view === "trackpad" ? ( + {renderLockTakeover ? ( +
+ + Screen locked + + {`Controls resume when you unlock ${HOSTNAME}`} + +
+ ) : ( + <> + {renderBlankBanner && ( +
+ + {`Screen asleep — press anything to wake ${HOSTNAME}`} +
+ )} + {view === "trackpad" ? ( { // Stage 3 (#122): raise the tapped window, then close // the overlay back to the focused-app layout. Same @@ -735,8 +813,10 @@ export function App() { ) : (
waiting for daemon…
)} + + )} - {jogstripEnabled && view !== "settings" && view !== "nowplaying" && view !== "editor" && view !== "windows" && ( + {jogstripEnabled && !renderLockTakeover && view !== "settings" && view !== "nowplaying" && view !== "editor" && view !== "windows" && (