diff --git a/app/src/features/desktop/DesktopGrants.tsx b/app/src/features/desktop/DesktopGrants.tsx
index 0de2b18d..1dd74b26 100644
--- a/app/src/features/desktop/DesktopGrants.tsx
+++ b/app/src/features/desktop/DesktopGrants.tsx
@@ -8,6 +8,7 @@ import type { Bot, DesktopGrant } from "../../lib/protocol.gen";
import { useApi } from "../../store/context";
import { Button } from "../../ui/Button";
import { attempt } from "../../ui/toast";
+import { RealInput } from "./RealInput";
/** The bot's grants, read when shown and kept current by `bot.desktop`. */
function useDesktopGrants(botId: Bot["id"]): DesktopGrant[] | null {
@@ -60,6 +61,7 @@ export function DesktopGrants({ bot }: { bot: Bot }) {
{grant.level === "act" ? words.act : words.see}
+ {grant.level === "act" && }
{grant.appPath && (
+ attempt(words.realFailed, () =>
+ api.call("desktop.setOptions", { grantId: grant.id, realInput }),
+ );
+ return (
+ <>
+
+ (on ? setAsking(true) : void set(false))}
+ />
+ {words.real}
+
+ {asking && (
+
+ )}
+ >
+ );
+}
diff --git a/app/src/features/desktop/realInput.test.tsx b/app/src/features/desktop/realInput.test.tsx
new file mode 100644
index 00000000..17fccc4f
--- /dev/null
+++ b/app/src/features/desktop/realInput.test.tsx
@@ -0,0 +1,74 @@
+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 openScout() {
+ 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");
+ openBot("Scout");
+ await screen.findByRole("list", { name: "Messages" });
+ return { fake, scout };
+}
+
+const details = () => screen.getByRole("complementary", { name: "About Scout" });
+
+describe("the real mouse and keyboard", () => {
+ test("are off, and turning them on says first what changes", async () => {
+ const { fake, scout } = await openScout();
+ act(() => {
+ fake.desktop.grant(scout.id, NOTEPAD, "Notepad", "act");
+ });
+ fireEvent.click(screen.getByRole("button", { name: /^Show details/ }));
+ const toggle = await within(details()).findByRole("switch", {
+ name: "Real mouse and keyboard: Notepad",
+ });
+ expect(toggle.getAttribute("aria-checked")).toBe("false");
+
+ fireEvent.click(toggle);
+ const dialog = screen.getByRole("dialog", {
+ name: "Let Scout use your real mouse and keyboard in Notepad?",
+ });
+ expect(within(dialog).getByText(/Move the mouse or press a key and it stops/)).toBeDefined();
+ expect(within(dialog).getByText(/Ctrl\+Alt\+Esc/)).toBeDefined();
+ fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
+ expect(fake.desktop.grants[0]?.realInput).toBe(false);
+
+ fireEvent.click(toggle);
+ fireEvent.click(screen.getByRole("button", { name: "Turn on" }));
+ await waitFor(() => expect(fake.desktop.grants[0]?.realInput).toBe(true));
+ expect(toggle.getAttribute("aria-checked")).toBe("true");
+
+ // Off at once, without asking.
+ fireEvent.click(toggle);
+ await waitFor(() => expect(fake.desktop.grants[0]?.realInput).toBe(false));
+ });
+
+ test("are not offered where the bot may only see", async () => {
+ const { fake, scout } = await openScout();
+ act(() => {
+ fake.desktop.grant(scout.id, NOTEPAD, "Notepad");
+ });
+ fireEvent.click(screen.getByRole("button", { name: /^Show details/ }));
+ await within(details()).findByText("Can see");
+ expect(within(details()).queryByRole("switch", { name: /Real mouse/ })).toBeNull();
+ });
+
+ test("the daemon hears the owner's language when it changes, for its notice", async () => {
+ const { fake } = await openScout();
+ act(() => setLocaleChoice("pt-BR"));
+ await waitFor(() => expect(fake.locale).toBe("pt-BR"));
+ });
+});
diff --git a/app/src/features/desktop/useLocaleToDaemon.ts b/app/src/features/desktop/useLocaleToDaemon.ts
new file mode 100644
index 00000000..884867a9
--- /dev/null
+++ b/app/src/features/desktop/useLocaleToDaemon.ts
@@ -0,0 +1,21 @@
+// The owner's language, told to the daemon when it changes, for the notice
+// it shows on screen while a bot uses the real mouse and keyboard (spec
+// 24.7). The language at connection goes with `session.hello`.
+
+import { useEffect, useRef } from "react";
+import { useLocale } from "../../i18n";
+import { useApi } from "../../store/context";
+
+export function useLocaleToDaemon(): void {
+ const api = useApi();
+ const { locale } = useLocale();
+ const told = useRef(locale);
+ useEffect(() => {
+ if (told.current === locale) {
+ return;
+ }
+ told.current = locale;
+ // An older daemon does not know the method: nothing to tell.
+ api.call("session.setLocale", { locale }).catch(() => {});
+ }, [api, locale]);
+}
diff --git a/app/src/i18n/en/desktop.ts b/app/src/i18n/en/desktop.ts
index 0465da3a..995e34c4 100644
--- a/app/src/i18n/en/desktop.ts
+++ b/app/src/i18n/en/desktop.ts
@@ -41,6 +41,7 @@ export const desktop = {
select: (option: string) => `Chose "${option}"`,
scroll: (target: string) => `Scrolled "${target}"`,
scrollSomething: "Scrolled",
+ press: (keys: string) => `Pressed ${keys}`,
showInPanel: "Watch on the desktop panel",
close: "Close",
},
@@ -53,5 +54,17 @@ export const desktop = {
whole: "The whole desktop",
remove: (app: string) => `Take back ${app}`,
removeFailed: "Could not take it back",
+ real: "Real mouse and keyboard",
+ realFailed: "Could not change it",
+ realTitle: (bot: string, app: string) =>
+ `Let ${bot} use your real mouse and keyboard in ${app}?`,
+ realPoints: (bot: string) => [
+ `When accessibility cannot do something in this app, ${bot} moves your cursor and types as if it were you.`,
+ `While it acts, your mouse and keyboard are its, and a notice on your screen says so.`,
+ `Move the mouse or press a key and it stops at once. Ctrl+Alt+Esc stops every bot on your desktop.`,
+ `It never types in password fields and never presses Windows' own keys, like the Windows key or Alt+Tab.`,
+ ],
+ realConfirm: "Turn on",
+ cancel: "Cancel",
},
};
diff --git a/app/src/i18n/es/desktop.ts b/app/src/i18n/es/desktop.ts
index e1ba5022..ffba854f 100644
--- a/app/src/i18n/es/desktop.ts
+++ b/app/src/i18n/es/desktop.ts
@@ -42,6 +42,7 @@ export const desktop: Messages["desktop"] = {
select: (option) => `Eligió "${option}"`,
scroll: (target) => `Desplazó "${target}"`,
scrollSomething: "Desplazó",
+ press: (keys) => `Presionó ${keys}`,
showInPanel: "Ver en el panel del escritorio",
close: "Cerrar",
},
@@ -54,5 +55,16 @@ export const desktop: Messages["desktop"] = {
whole: "Todo el escritorio",
remove: (app) => `Quitar ${app}`,
removeFailed: "No se pudo quitar",
+ real: "Mouse y teclado de verdad",
+ realFailed: "No se pudo cambiar",
+ realTitle: (bot, app) => `¿Dejar que ${bot} use tu mouse y teclado de verdad en ${app}?`,
+ realPoints: (bot) => [
+ `Cuando la accesibilidad no alcanza algo en esa app, ${bot} mueve tu cursor y escribe como si fueras tú.`,
+ `Mientras actúa, el mouse y el teclado son suyos, y un aviso en tu pantalla lo dice.`,
+ `Mueve el mouse o presiona una tecla y se detiene al instante. Ctrl+Alt+Esc detiene a todos los bots en tu escritorio.`,
+ `Nunca escribe en campos de contraseña ni presiona teclas propias de Windows, como la tecla Windows o Alt+Tab.`,
+ ],
+ realConfirm: "Activar",
+ cancel: "Cancelar",
},
};
diff --git a/app/src/i18n/pt-BR/desktop.ts b/app/src/i18n/pt-BR/desktop.ts
index e8708e70..9649f993 100644
--- a/app/src/i18n/pt-BR/desktop.ts
+++ b/app/src/i18n/pt-BR/desktop.ts
@@ -41,6 +41,7 @@ export const desktop: Messages["desktop"] = {
select: (option) => `Escolheu "${option}"`,
scroll: (target) => `Rolou "${target}"`,
scrollSomething: "Rolou",
+ press: (keys) => `Apertou ${keys}`,
showInPanel: "Ver no painel do desktop",
close: "Fechar",
},
@@ -53,5 +54,16 @@ export const desktop: Messages["desktop"] = {
whole: "O desktop inteiro",
remove: (app) => `Tirar ${app}`,
removeFailed: "Não foi possível tirar",
+ real: "Mouse e teclado de verdade",
+ realFailed: "Não foi possível mudar",
+ realTitle: (bot, app) => `Deixar ${bot} usar seu mouse e teclado de verdade no ${app}?`,
+ realPoints: (bot) => [
+ `Quando a acessibilidade não alcança algo nesse app, ${bot} move o seu cursor e digita como se fosse você.`,
+ `Enquanto ele age, o mouse e o teclado são dele, e um aviso na sua tela diz isso.`,
+ `Mexa o mouse ou aperte uma tecla e ele para na hora. Ctrl+Alt+Esc para todos os bots no seu desktop.`,
+ `Ele nunca digita em campos de senha e nunca aperta teclas do próprio Windows, como a tecla Windows ou Alt+Tab.`,
+ ],
+ realConfirm: "Ligar",
+ cancel: "Cancelar",
},
};
diff --git a/app/src/lib/fake.ts b/app/src/lib/fake.ts
index dfcc107c..00fd0d32 100644
--- a/app/src/lib/fake.ts
+++ b/app/src/lib/fake.ts
@@ -75,6 +75,8 @@ export class FakeBotloft implements BotloftApi {
readonly chat = new FakeChat(this);
readonly allow = new FakeAllow(this);
readonly desktop = new FakeDesktop(this);
+ /** The language the app last told, as the daemon keeps it (spec 24.7). */
+ locale: string | null = null;
readonly conversation = new FakeConversation(this);
readonly routines = new FakeRoutines(this);
readonly files = new FakeFiles(this);
@@ -239,6 +241,10 @@ export class FakeBotloft implements BotloftApi {
daemonVersion: this.system.daemonVersion,
protocol: PROTOCOL_VERSION,
}),
+ "session.setLocale": ({ locale }) => {
+ this.locale = locale;
+ return null;
+ },
"system.status": () => this.system,
"system.refresh": () => {
this.refreshes += 1;
diff --git a/app/src/lib/fakeDesktop.ts b/app/src/lib/fakeDesktop.ts
index 6f0e1de1..93c014e9 100644
--- a/app/src/lib/fakeDesktop.ts
+++ b/app/src/lib/fakeDesktop.ts
@@ -104,6 +104,17 @@ export class FakeDesktop {
this.changed(grant.botId);
return { botId: grant.botId, grants: this.of(grant.botId) };
},
+ "desktop.setOptions": ({ grantId, realInput }) => {
+ const grant = this.grants.find((each) => each.id === grantId);
+ if (!grant) {
+ throw notFound(`desktop grant ${grantId}`);
+ }
+ if (realInput !== undefined) {
+ grant.realInput = realInput;
+ }
+ this.changed(grant.botId);
+ return { botId: grant.botId, grants: this.of(grant.botId) };
+ },
"desktop.watch": ({ botId }) => {
this.fake.bot(botId);
this.watching = botId;
diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts
index a54e9090..57dc2efb 100644
--- a/app/src/lib/protocol.gen.ts
+++ b/app/src/lib/protocol.gen.ts
@@ -265,7 +265,12 @@ archivedAt: number | null, };
/**
* Identifies the connecting app in `session.hello`.
*/
-export type ClientInfo = { name: string, version: string, };
+export type ClientInfo = { name: string, version: string,
+/**
+ * The language the owner reads the app in ("en", "pt-BR", "es"), for
+ * what the daemon itself shows them (spec 24.7).
+ */
+locale?: string, };
/**
* First request of every connection.
@@ -276,6 +281,11 @@ export type HelloParams = {
*/
token: string, client: ClientInfo, protocol: number, };
+/**
+ * `session.setLocale`: the owner changed the app's language.
+ */
+export type LocaleParams = { locale: string, };
+
export type HelloResult = { daemonVersion: string, protocol: number, };
/**
@@ -859,6 +869,15 @@ export type DesktopGrantsParams = { botId: BotId, };
export type DesktopGrantIdParams = { grantId: DesktopGrantId, };
+/**
+ * `desktop.setOptions`: what the owner changes in a grant (spec 24.10).
+ */
+export type DesktopOptionsParams = { grantId: DesktopGrantId,
+/**
+ * The bot may use the real mouse and keyboard there (spec 24.7).
+ */
+realInput?: boolean, };
+
/**
* A bot's grants after one changed: the result of `desktop.revoke` and
* the params of the `bot.desktop` notification.
@@ -882,7 +901,7 @@ app: string, };
* What a bot did in a window (spec 24.5), for its panel, which words it
* in the owner's language.
*/
-export type DesktopActionKind = "click" | "type" | "select" | "scroll";
+export type DesktopActionKind = "click" | "type" | "select" | "scroll" | "press";
export type DesktopAction = { kind: DesktopActionKind,
/**
@@ -1580,6 +1599,7 @@ done: boolean, };
/** Params and result of every request method. */
export interface RpcMethods {
"session.hello": { params: HelloParams; result: HelloResult };
+ "session.setLocale": { params: LocaleParams; result: null };
"system.status": { params: undefined; result: SystemStatus };
"system.refresh": { params: undefined; result: SystemStatus };
"settings.get": { params: undefined; result: Settings };
@@ -1619,6 +1639,7 @@ export interface RpcMethods {
"rules.delete": { params: RuleIdParams; result: BotRules };
"desktop.grants": { params: DesktopGrantsParams; result: Array };
"desktop.revoke": { params: DesktopGrantIdParams; result: BotDesktop };
+ "desktop.setOptions": { params: DesktopOptionsParams; result: BotDesktop };
"desktop.watch": { params: DesktopBotParams; result: DesktopView };
"desktop.unwatch": { params: undefined; result: null };
"desktop.stop": { params: DesktopBotParams; result: DesktopState };
diff --git a/app/src/main.tsx b/app/src/main.tsx
index 39e25a73..6ff65f30 100644
--- a/app/src/main.tsx
+++ b/app/src/main.tsx
@@ -4,6 +4,7 @@ import packageJson from "../package.json";
import { App } from "./App";
import { startEyeMoments } from "./features/bots/eyeMoments";
import type { Connect } from "./features/onboarding/link";
+import { currentLocale } from "./i18n";
import { connect, rpcUrl } from "./lib/client";
import type { Host } from "./lib/host";
import { tauriHost } from "./lib/tauriHost";
@@ -22,7 +23,9 @@ async function props(): Promise<{ host: Host; connect: Connect }> {
const client = { name: "botloft-app", version: packageJson.version };
return {
host: tauriHost(),
- connect: (port, token) => connect({ url: rpcUrl(port), token, client }),
+ // Its language, for what the daemon shows on screen (spec 24.7).
+ connect: (port, token) =>
+ connect({ url: rpcUrl(port), token, client: { ...client, locale: currentLocale() } }),
};
}
if (import.meta.env.DEV) {
diff --git a/app/src/shell/Workspace.tsx b/app/src/shell/Workspace.tsx
index aeea1ff6..0098a809 100644
--- a/app/src/shell/Workspace.tsx
+++ b/app/src/shell/Workspace.tsx
@@ -8,6 +8,7 @@ import { BotView } from "../features/bots/BotView";
import { CrewsOverview } from "../features/crews/CrewsOverview";
import { CrewView } from "../features/crews/CrewView";
import { Sidebar } from "../features/crews/Sidebar";
+import { useLocaleToDaemon } from "../features/desktop/useLocaleToDaemon";
import { FailedDeliveries } from "../features/messages/FailedDeliveries";
import { ClaudeCodeHelp } from "../features/onboarding/ClaudeCodeHelp";
import { SignInButton } from "../features/onboarding/SignIn";
@@ -32,6 +33,7 @@ import { useTray } from "./tray";
export function Workspace() {
const t = useT();
+ useLocaleToDaemon();
const loaded = useApp((state) => state.loaded);
const loadError = useApp((state) => state.loadError);
const hasCrews = useApp((state) => Object.keys(state.crews).length > 0);
diff --git a/crates/botloft-core/src/protocol/desktop.rs b/crates/botloft-core/src/protocol/desktop.rs
index 32f88849..38743aa5 100644
--- a/crates/botloft-core/src/protocol/desktop.rs
+++ b/crates/botloft-core/src/protocol/desktop.rs
@@ -97,6 +97,18 @@ pub struct DesktopGrantIdParams {
pub grant_id: DesktopGrantId,
}
+/// `desktop.setOptions`: what the owner changes in a grant (spec 24.10).
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+#[cfg_attr(test, derive(ts_rs::TS))]
+pub struct DesktopOptionsParams {
+ pub grant_id: DesktopGrantId,
+ /// The bot may use the real mouse and keyboard there (spec 24.7).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ #[cfg_attr(test, ts(optional))]
+ pub real_input: Option,
+}
+
/// A bot's grants after one changed: the result of `desktop.revoke` and
/// the params of the `bot.desktop` notification.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -129,6 +141,8 @@ pub enum DesktopActionKind {
Type,
Select,
Scroll,
+ /// Keys pressed with the real keyboard; `option` says which.
+ Press,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs
index fbf832b8..e7c3273c 100644
--- a/crates/botloft-core/src/protocol/export.rs
+++ b/crates/botloft-core/src/protocol/export.rs
@@ -120,6 +120,7 @@ fn export_bindings() {
out.decl::();
out.decl::();
out.decl::();
+ out.decl::();
out.decl::();
out.decl::();
out.decl::();
@@ -211,6 +212,11 @@ fn export_bindings() {
&out.name::(),
&out.name::(),
);
+ out.method(
+ method::SESSION_SET_LOCALE,
+ &out.name::(),
+ "null",
+ );
out.method(
method::SYSTEM_STATUS,
"undefined",
diff --git a/crates/botloft-core/src/protocol/export_desktop.rs b/crates/botloft-core/src/protocol/export_desktop.rs
index 0230b6ca..bb181620 100644
--- a/crates/botloft-core/src/protocol/export_desktop.rs
+++ b/crates/botloft-core/src/protocol/export_desktop.rs
@@ -11,6 +11,7 @@ pub(super) fn decls(out: &mut Out) {
out.decl::();
out.decl::();
out.decl::();
+ out.decl::();
out.decl::();
out.decl::();
out.decl::();
@@ -32,6 +33,11 @@ pub(super) fn methods(out: &mut Out) {
&out.name::(),
&out.name::(),
);
+ out.method(
+ method::DESKTOP_SET_OPTIONS,
+ &out.name::(),
+ &out.name::(),
+ );
let bot = out.name::();
out.method(method::DESKTOP_WATCH, &bot, &out.name::());
out.method(method::DESKTOP_UNWATCH, "undefined", "null");
diff --git a/crates/botloft-core/src/protocol/methods.rs b/crates/botloft-core/src/protocol/methods.rs
index db17f0d3..b39cd1bd 100644
--- a/crates/botloft-core/src/protocol/methods.rs
+++ b/crates/botloft-core/src/protocol/methods.rs
@@ -14,6 +14,19 @@ use crate::ids::{BotId, CrewId};
pub struct ClientInfo {
pub name: String,
pub version: String,
+ /// The language the owner reads the app in ("en", "pt-BR", "es"), for
+ /// what the daemon itself shows them (spec 24.7).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ #[cfg_attr(test, ts(optional))]
+ pub locale: Option,
+}
+
+/// `session.setLocale`: the owner changed the app's language.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+#[cfg_attr(test, derive(ts_rs::TS))]
+pub struct LocaleParams {
+ pub locale: String,
}
/// First request of every connection.
diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs
index cac4565e..772e478f 100644
--- a/crates/botloft-core/src/protocol/mod.rs
+++ b/crates/botloft-core/src/protocol/mod.rs
@@ -96,6 +96,7 @@ pub const PROTOCOL_VERSION: u32 = 2;
/// Request method names.
pub mod method {
pub const SESSION_HELLO: &str = "session.hello";
+ pub const SESSION_SET_LOCALE: &str = "session.setLocale";
pub const SYSTEM_STATUS: &str = "system.status";
pub const SYSTEM_REFRESH: &str = "system.refresh";
pub const CREWS_LIST: &str = "crews.list";
@@ -125,6 +126,7 @@ pub mod method {
pub const RULES_DELETE: &str = "rules.delete";
pub const DESKTOP_GRANTS: &str = "desktop.grants";
pub const DESKTOP_REVOKE: &str = "desktop.revoke";
+ pub const DESKTOP_SET_OPTIONS: &str = "desktop.setOptions";
pub const DESKTOP_WATCH: &str = "desktop.watch";
pub const DESKTOP_UNWATCH: &str = "desktop.unwatch";
pub const DESKTOP_STOP: &str = "desktop.stop";
diff --git a/crates/botloft-store/src/desktop_grants.rs b/crates/botloft-store/src/desktop_grants.rs
index 4cb0092d..0d39c9f3 100644
--- a/crates/botloft-store/src/desktop_grants.rs
+++ b/crates/botloft-store/src/desktop_grants.rs
@@ -80,6 +80,36 @@ impl Store {
Ok(grants)
}
+ /// Lets the bot use the owner's real mouse and keyboard in the grant's
+ /// reach, or not (spec 24.7); the bot the grant belongs to, or `None`
+ /// if there is no such grant.
+ pub fn set_desktop_real_input(
+ &self,
+ grant: &DesktopGrantId,
+ on: bool,
+ ) -> Result