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
84 changes: 84 additions & 0 deletions app/src/features/desktop/AwayNotice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// What bots did on the owner's desktop while the owner was away (spec
// 24.8): one line per bot and app above the main pane, each opening the
// bot's chat where it began, until the owner dismisses them.

import { MessageSquare, Monitor } from "lucide-react";
import { useEffect, useState } from "react";
import { useT } from "../../i18n";
import type { DesktopAwayUse } from "../../lib/protocol.gen";
import { useApi, useApp } from "../../store/context";
import { Button } from "../../ui/Button";
import { attempt } from "../../ui/toast";

/** The list now, kept current by `desktop.away`. */
function useAwayUses(): DesktopAwayUse[] {
const api = useApi();
const [uses, setUses] = useState<DesktopAwayUse[]>([]);
useEffect(() => {
let alive = true;
let heard = false;
const stop = api.subscribe((event) => {
if (event.name === "desktop.away") {
heard = true;
setUses(event.params);
}
});
api.call("desktop.awayUses").then(
(listed) => alive && !heard && setUses(listed),
// An older daemon has none.
() => undefined,
);
return () => {
alive = false;
stop();
};
}, [api]);
return uses;
}

export function AwayNotice() {
const words = useT().desktop.away;
const api = useApi();
const uses = useAwayUses();
const bots = useApp((state) => state.bots);
const openAt = useApp((state) => state.openAt);
const selectBot = useApp((state) => state.selectBot);
const shown = uses.filter((used) => bots[used.botId]);
if (shown.length === 0) {
return null;
}
return (
<section
aria-label={words.label}
className="flex items-start gap-3 border-line border-b bg-sunken px-4 py-2.5"
>
<Monitor aria-hidden size={16} className="mt-1 shrink-0 text-muted" />
<ul className="flex min-w-0 flex-1 flex-col gap-1">
{shown.map((used) => (
<li key={`${used.botId} ${used.app}`} className="flex items-center gap-2 text-sm">
<span className="min-w-0 flex-1">
{words.used(bots[used.botId]?.name ?? "", used.app)}
</span>
<Button
variant="ghost"
size="sm"
icon={MessageSquare}
onClick={() =>
used.itemId ? openAt(used.botId, used.itemId) : selectBot(used.botId)
}
>
{words.open}
</Button>
</li>
))}
</ul>
<Button
variant="secondary"
size="sm"
onClick={() => attempt(words.dismissFailed, () => api.call("desktop.dismissAway"))}
>
{words.dismiss}
</Button>
</section>
);
}
2 changes: 2 additions & 0 deletions app/src/features/desktop/DesktopGrants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useApi } from "../../store/context";
import { Button } from "../../ui/Button";
import { attempt } from "../../ui/toast";
import { RealInput } from "./RealInput";
import { Unattended } from "./Unattended";

/** The bot's grants, read when shown and kept current by `bot.desktop`. */
function useDesktopGrants(botId: Bot["id"]): DesktopGrant[] | null {
Expand Down Expand Up @@ -62,6 +63,7 @@ export function DesktopGrants({ bot }: { bot: Bot }) {
{grant.level === "act" ? words.act : words.see}
</span>
{grant.level === "act" && <RealInput bot={bot} grant={grant} app={app} />}
<Unattended bot={bot} grant={grant} app={app} />
{grant.appPath && (
<span
className="block break-all font-mono text-muted text-xs"
Expand Down
83 changes: 83 additions & 0 deletions app/src/features/desktop/Unattended.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// The switch of a desktop grant for use while the owner is away (spec
// 24.8, 24.10): off from the start; turning it on first shows the risks,
// and the confirm button lights only once the owner checks that they
// understand them; turning it off is at once.

import { Check } from "lucide-react";
import { useState } from "react";
import { useT } from "../../i18n";
import type { Bot, DesktopGrant } from "../../lib/protocol.gen";
import { useApi } from "../../store/context";
import { Button } from "../../ui/Button";
import { Dialog } from "../../ui/Dialog";
import { Switch } from "../../ui/Switch";
import { attempt } from "../../ui/toast";

export function Unattended({ bot, grant, app }: { bot: Bot; grant: DesktopGrant; app: string }) {
const words = useT().desktop.grants;
const api = useApi();
const [asking, setAsking] = useState(false);
const [understood, setUnderstood] = useState(false);
const set = (unattended: boolean) =>
attempt(words.realFailed, () =>
api.call("desktop.setOptions", {
grantId: grant.id,
unattended,
...(unattended ? { acceptedRisks: true } : {}),
}),
);
const close = () => {
setAsking(false);
setUnderstood(false);
};
return (
<>
<span className="mt-1 flex items-center gap-2 text-xs">
<Switch
checked={grant.unattended}
label={`${words.away}: ${app}`}
onChange={(on) => (on ? setAsking(true) : void set(false))}
/>
<span className="text-ink-soft">{words.away}</span>
</span>
{asking && (
<Dialog
title={words.awayTitle(bot.name, app)}
onClose={close}
footer={
<>
<Button variant="ghost" onClick={close}>
{words.cancel}
</Button>
<Button
variant="primary"
icon={Check}
disabled={!understood}
onClick={() => {
close();
void set(true);
}}
>
{words.awayConfirm}
</Button>
</>
}
>
<ul className="flex list-disc flex-col gap-1.5 pl-5 text-sm">
{words.awayRisks(bot.name, app).map((risk) => (
<li key={risk}>{risk}</li>
))}
</ul>
<label className="mt-4 flex items-center gap-2 font-medium text-sm">
<input
type="checkbox"
checked={understood}
onChange={(event) => setUnderstood(event.target.checked)}
/>
{words.awayUnderstood}
</label>
</Dialog>
)}
</>
);
}
94 changes: 94 additions & 0 deletions app/src/features/desktop/away.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { setLocaleChoice } from "../../i18n";
import { FakeBotloft } from "../../lib/fake";
import { crewOpened, openBot, renderApp } from "../../test/app";

afterEach(() => {
act(() => setLocaleChoice("en"));
cleanup();
});

const NOTEPAD = "C:\\Windows\\notepad.exe";

async function opened() {
const fake = new FakeBotloft();
const ops = fake.addCrew("Ops");
const scout = fake.addBot(ops.id, "Scout", "Finds sources");
fake.setBotState(scout.id, "idle");
renderApp(fake);
await crewOpened("Ops");
return { fake, scout };
}

describe("use while the owner is away", () => {
test("is off, and goes on only after the owner checks that they understand the risks", async () => {
const { fake, scout } = await opened();
openBot("Scout");
await screen.findByRole("list", { name: "Messages" });
act(() => {
fake.desktop.grant(scout.id, NOTEPAD, "Notepad");
});
fireEvent.click(screen.getByRole("button", { name: /^Show details/ }));
const details = screen.getByRole("complementary", { name: "About Scout" });
const toggle = await within(details).findByRole("switch", {
name: "While you are away: Notepad",
});
expect(toggle.getAttribute("aria-checked")).toBe("false");

fireEvent.click(toggle);
const dialog = screen.getByRole("dialog", {
name: "Let Scout work in Notepad while you are away?",
});
expect(within(dialog).getByText(/Scout can make mistakes/)).toBeDefined();
expect(within(dialog).getByText(/can try to trick Scout/)).toBeDefined();
expect(within(dialog).getByText(/nobody watches as it happens/)).toBeDefined();
expect(within(dialog).getByText(/password managers/)).toBeDefined();
const confirm = within(dialog).getByRole("button", { name: "Turn on" }) as HTMLButtonElement;
expect(confirm.disabled).toBe(true);
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
expect(fake.desktop.grants[0]?.unattended).toBe(false);

// Asked again, the box starts unchecked.
fireEvent.click(toggle);
const again = screen.getByRole("dialog");
const box = within(again).getByRole("checkbox", { name: "I understand the risks" });
expect((box as HTMLInputElement).checked).toBe(false);
fireEvent.click(box);
fireEvent.click(within(again).getByRole("button", { name: "Turn on" }));
await waitFor(() => expect(fake.desktop.grants[0]?.unattended).toBe(true));
expect(fake.desktop.grants[0]?.acceptedRisksAt).not.toBeNull();
expect(toggle.getAttribute("aria-checked")).toBe("true");

// Off at once, without asking.
fireEvent.click(toggle);
await waitFor(() => expect(fake.desktop.grants[0]?.unattended).toBe(false));
}, 15_000);

test("the owner hears, when back, what each bot used and opens its chat", async () => {
const { fake, scout } = await opened();
act(() => {
fake.desktop.usedAway(scout.id, "Notepad");
});
const notice = await screen.findByRole("region", { name: "While you were away" });
expect(within(notice).getByText("Scout used Notepad while you were away")).toBeDefined();

fireEvent.click(within(notice).getByRole("button", { name: "See in the chat" }));
await screen.findByRole("list", { name: "Messages" });

fireEvent.click(within(notice).getByRole("button", { name: "Got it" }));
await waitFor(() =>
expect(screen.queryByRole("region", { name: "While you were away" })).toBeNull(),
);
expect(fake.desktop.away).toEqual([]);
});

test("speaks the owner's language", async () => {
const { fake, scout } = await opened();
act(() => setLocaleChoice("pt-BR"));
act(() => {
fake.desktop.usedAway(scout.id, "Excel");
});
expect(await screen.findByText("Scout usou o Excel enquanto você estava fora")).toBeDefined();
});
});
2 changes: 1 addition & 1 deletion app/src/features/desktop/realInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ describe("the real mouse and keyboard", () => {
// Off at once, without asking.
fireEvent.click(toggle);
await waitFor(() => expect(fake.desktop.grants[0]?.realInput).toBe(false));
});
}, 15_000);

test("are not offered where the bot may only see", async () => {
const { fake, scout } = await openScout();
Expand Down
18 changes: 18 additions & 0 deletions app/src/i18n/en/desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,5 +66,23 @@ export const desktop = {
],
realConfirm: "Turn on",
cancel: "Cancel",
away: "While you are away",
awayTitle: (bot: string, app: string) => `Let ${bot} work in ${app} while you are away?`,
awayRisks: (bot: string, app: string) => [
`${bot} sees everything open in ${app}, personal data, emails, chats and client data included.`,
`${bot} can make mistakes: delete, send or change something in the app as if it were you.`,
`Text in an app or a message can try to trick ${bot} into doing something else.`,
"With you away, nobody watches as it happens. You see it later, in the chat, and Botloft tells you when you are back.",
"Never, not even then: Botloft itself, apps running as administrator, terminals and command boxes, the Task Manager, password managers and password fields.",
],
awayUnderstood: "I understand the risks",
awayConfirm: "Turn on",
},
away: {
label: "While you were away",
used: (bot: string, app: string) => `${bot} used ${app} while you were away`,
open: "See in the chat",
dismiss: "Got it",
dismissFailed: "Could not hide it",
},
};
18 changes: 18 additions & 0 deletions app/src/i18n/es/desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,5 +66,23 @@ export const desktop: Messages["desktop"] = {
],
realConfirm: "Activar",
cancel: "Cancelar",
away: "Sin ti delante",
awayTitle: (bot, app) => `¿Dejar que ${bot} trabaje en ${app} sin ti delante?`,
awayRisks: (bot, app) => [
`${bot} ve todo lo que esté abierto en ${app}, incluidos datos personales, correos, conversaciones y datos de clientes.`,
`${bot} puede equivocarse: borrar, enviar o cambiar algo en la app como si fueras tú.`,
`Un texto en una app o en un mensaje puede intentar engañar a ${bot} para que haga otra cosa.`,
"Sin ti delante, nadie lo ve en el momento. Lo ves después, en el chat, y Botloft te avisa cuando vuelvas.",
"Nunca, ni así: el propio Botloft, apps que se ejecutan como administrador, terminales y ventanas de comandos, el Administrador de tareas, gestores de contraseñas y campos de contraseña.",
],
awayUnderstood: "Entiendo los riesgos",
awayConfirm: "Activar",
},
away: {
label: "Mientras no estabas",
used: (bot, app) => `${bot} usó ${app} mientras no estabas`,
open: "Ver en el chat",
dismiss: "Entendido",
dismissFailed: "No se pudo ocultar",
},
};
18 changes: 18 additions & 0 deletions app/src/i18n/pt-BR/desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,5 +65,23 @@ export const desktop: Messages["desktop"] = {
],
realConfirm: "Ligar",
cancel: "Cancelar",
away: "Sem você na frente",
awayTitle: (bot, app) => `Deixar ${bot} trabalhar no ${app} sem você na frente?`,
awayRisks: (bot, app) => [
`${bot} vê tudo o que estiver aberto no ${app}, inclusive dados pessoais, e-mails, conversas e dados de clientes.`,
`${bot} pode errar: apagar, enviar ou mudar algo no app como se fosse você.`,
`Um texto num app ou numa mensagem pode tentar enganar ${bot} para fazer outra coisa.`,
"Sem você na frente, ninguém vê na hora. Você vê depois, no chat, e o Botloft avisa quando você voltar.",
"Nunca, nem assim: o próprio Botloft, apps rodando como administrador, terminais e caixas de comando, o Gerenciador de Tarefas, gerenciadores de senha e campos de senha.",
],
awayUnderstood: "Entendi os riscos",
awayConfirm: "Ligar",
},
away: {
label: "Enquanto você estava fora",
used: (bot, app) => `${bot} usou o ${app} enquanto você estava fora`,
open: "Ver no chat",
dismiss: "Entendi",
dismissFailed: "Não foi possível esconder",
},
};
Loading
Loading