diff --git a/app/src/features/desktop/DesktopGrants.tsx b/app/src/features/desktop/DesktopGrants.tsx
index d966a064..6a7f0726 100644
--- a/app/src/features/desktop/DesktopGrants.tsx
+++ b/app/src/features/desktop/DesktopGrants.tsx
@@ -10,6 +10,7 @@ import { Button } from "../../ui/Button";
import { attempt } from "../../ui/toast";
import { RealInput } from "./RealInput";
import { Unattended } from "./Unattended";
+import { WholeDesktop } from "./WholeDesktop";
/** The bot's grants, read when shown and kept current by `bot.desktop`. */
export function useDesktopGrants(botId: Bot["id"]): DesktopGrant[] | null {
@@ -89,6 +90,11 @@ export function DesktopGrants({ bot }: { bot: Bot }) {
})}
)}
+ {grants !== null && !grants.some((grant) => grant.scope === "desktop") && (
+
+
+
+ )}
);
diff --git a/app/src/features/desktop/WholeDesktop.tsx b/app/src/features/desktop/WholeDesktop.tsx
new file mode 100644
index 00000000..edd20e10
--- /dev/null
+++ b/app/src/features/desktop/WholeDesktop.tsx
@@ -0,0 +1,85 @@
+// Giving a bot the owner's whole desktop (spec 24.2, 24.10): a button in
+// the bot's details that opens the risks screen, with the level to give;
+// the confirm button lights only once the owner checks that they
+// understand the risks. Never asked for in the chat.
+
+import { Check, Monitor } from "lucide-react";
+import { useState } from "react";
+import { useT } from "../../i18n";
+import type { Bot, DesktopLevel } from "../../lib/protocol.gen";
+import { useApi } from "../../store/context";
+import { Button } from "../../ui/Button";
+import { Choices } from "../../ui/Choices";
+import { Dialog } from "../../ui/Dialog";
+import { attempt } from "../../ui/toast";
+
+export function WholeDesktop({ bot }: { bot: Bot }) {
+ const words = useT().desktop.grants;
+ const api = useApi();
+ const [asking, setAsking] = useState(false);
+ const [level, setLevel] = useState("see");
+ const [understood, setUnderstood] = useState(false);
+ const close = () => {
+ setAsking(false);
+ setUnderstood(false);
+ setLevel("see");
+ };
+ const give = () =>
+ attempt(words.wholeFailed, () =>
+ api.call("desktop.grantWhole", { botId: bot.id, level, acceptedRisks: true }),
+ );
+ return (
+ <>
+
+ {asking && (
+
+ )}
+ >
+ );
+}
diff --git a/app/src/features/desktop/whole.test.tsx b/app/src/features/desktop/whole.test.tsx
new file mode 100644
index 00000000..55cae956
--- /dev/null
+++ b/app/src/features/desktop/whole.test.tsx
@@ -0,0 +1,60 @@
+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();
+});
+
+async function details() {
+ 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" });
+ fireEvent.click(screen.getByRole("button", { name: /^Show details/ }));
+ const about = screen.getByRole("complementary", { name: "About Scout" });
+ return { fake, scout, about };
+}
+
+describe("the whole desktop", () => {
+ test("is given only after the owner chooses a level and accepts the risks", async () => {
+ const { fake, about } = await details();
+ fireEvent.click(await within(about).findByRole("button", { name: "Give the whole desktop" }));
+ const dialog = screen.getByRole("dialog", { name: "Give Scout your whole desktop?" });
+ expect(within(dialog).getByText(/without asking you first/)).toBeDefined();
+ expect(within(dialog).getByText(/covered in black/)).toBeDefined();
+ const confirm = within(dialog).getByRole("button", { name: "Give it" }) as HTMLButtonElement;
+ expect(confirm.disabled).toBe(true);
+
+ fireEvent.click(within(dialog).getByRole("radio", { name: "Can see and use" }));
+ fireEvent.click(within(dialog).getByRole("checkbox", { name: "I understand the risks" }));
+ expect(confirm.disabled).toBe(false);
+ fireEvent.click(confirm);
+ await waitFor(() => expect(fake.desktop.grants[0]?.scope).toBe("desktop"));
+ expect(fake.desktop.grants[0]?.level).toBe("act");
+ expect(fake.desktop.grants[0]?.acceptedRisksAt).not.toBeNull();
+
+ // Listed like any grant, with its options; the button goes.
+ expect(await within(about).findByText("The whole desktop")).toBeDefined();
+ expect(
+ within(about).getByRole("switch", { name: "Real mouse and keyboard: The whole desktop" }),
+ ).toBeDefined();
+ expect(within(about).queryByRole("button", { name: "Give the whole desktop" })).toBeNull();
+ }, 15_000);
+
+ test("speaks the owner's language", async () => {
+ const { about } = await details();
+ act(() => setLocaleChoice("pt-BR"));
+ fireEvent.click(await within(about).findByRole("button", { name: "Dar o desktop inteiro" }));
+ expect(
+ screen.getByRole("dialog", { name: "Dar a Scout o seu desktop inteiro?" }),
+ ).toBeDefined();
+ });
+});
diff --git a/app/src/i18n/en/desktop.ts b/app/src/i18n/en/desktop.ts
index 8cafa537..fb36ee0d 100644
--- a/app/src/i18n/en/desktop.ts
+++ b/app/src/i18n/en/desktop.ts
@@ -79,6 +79,18 @@ export const desktop = {
],
awayUnderstood: "I understand the risks",
awayConfirm: "Turn on",
+ giveWhole: "Give the whole desktop",
+ wholeFailed: "Could not give the whole desktop",
+ wholeTitle: (bot: string) => `Give ${bot} your whole desktop?`,
+ wholeLevel: "What it may do",
+ wholeRisks: (bot: string) => [
+ `${bot} sees every window open on your computer, personal data, emails, chats and client data included, and pictures of the whole screen.`,
+ `It reads any app without asking you first. If you choose "Can see and use", it also clicks and types in any of them.`,
+ `${bot} can make mistakes: delete, send or change something as if it were you.`,
+ `Text in an app or a message can try to trick ${bot} into doing something else.`,
+ "Never, not even then: Botloft itself, apps running as administrator, terminals and command boxes, the Task Manager, password managers and password fields. They are covered in black in its pictures.",
+ ],
+ wholeConfirm: "Give it",
},
away: {
label: "While you were away",
diff --git a/app/src/i18n/es/desktop.ts b/app/src/i18n/es/desktop.ts
index 2725640e..6d952ee8 100644
--- a/app/src/i18n/es/desktop.ts
+++ b/app/src/i18n/es/desktop.ts
@@ -79,6 +79,18 @@ export const desktop: Messages["desktop"] = {
],
awayUnderstood: "Entiendo los riesgos",
awayConfirm: "Activar",
+ giveWhole: "Dar todo el escritorio",
+ wholeFailed: "No se pudo dar todo el escritorio",
+ wholeTitle: (bot) => `¿Dar a ${bot} todo tu escritorio?`,
+ wholeLevel: "Lo que puede hacer",
+ wholeRisks: (bot) => [
+ `${bot} ve toda ventana abierta en tu computadora, incluidos datos personales, correos, conversaciones y datos de clientes, y fotos de toda la pantalla.`,
+ `Lee cualquier app sin preguntarte antes. Si eliges "Puede ver y usar", también hace clic y escribe en cualquiera.`,
+ `${bot} puede equivocarse: borrar, enviar o cambiar algo como si fueras tú.`,
+ `Un texto en una app o en un mensaje puede intentar engañar a ${bot} para que haga otra cosa.`,
+ "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. En sus fotos quedan cubiertos de negro.",
+ ],
+ wholeConfirm: "Dar",
},
away: {
label: "Mientras no estabas",
diff --git a/app/src/i18n/pt-BR/desktop.ts b/app/src/i18n/pt-BR/desktop.ts
index e7fccead..ed782212 100644
--- a/app/src/i18n/pt-BR/desktop.ts
+++ b/app/src/i18n/pt-BR/desktop.ts
@@ -78,6 +78,18 @@ export const desktop: Messages["desktop"] = {
],
awayUnderstood: "Entendi os riscos",
awayConfirm: "Ligar",
+ giveWhole: "Dar o desktop inteiro",
+ wholeFailed: "Não foi possível dar o desktop inteiro",
+ wholeTitle: (bot) => `Dar a ${bot} o seu desktop inteiro?`,
+ wholeLevel: "O que ele pode fazer",
+ wholeRisks: (bot) => [
+ `${bot} vê toda janela aberta no seu computador, inclusive dados pessoais, e-mails, conversas e dados de clientes, e fotos da tela inteira.`,
+ `Ele lê qualquer app sem te perguntar antes. Escolhendo "Pode ver e usar", também clica e digita em qualquer um.`,
+ `${bot} pode errar: apagar, enviar ou mudar algo como se fosse você.`,
+ `Um texto num app ou numa mensagem pode tentar enganar ${bot} para fazer outra coisa.`,
+ "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. Nas fotos dele, eles ficam cobertos de preto.",
+ ],
+ wholeConfirm: "Dar",
},
away: {
label: "Enquanto você estava fora",
diff --git a/app/src/lib/fakeDesktop.ts b/app/src/lib/fakeDesktop.ts
index 9a71714e..04616faa 100644
--- a/app/src/lib/fakeDesktop.ts
+++ b/app/src/lib/fakeDesktop.ts
@@ -154,6 +154,34 @@ export class FakeDesktop {
this.fake.bot(botId);
return this.set(botId, { stopped: false });
},
+ "desktop.grantWhole": ({ botId, level, acceptedRisks }) => {
+ this.fake.bot(botId);
+ if (!acceptedRisks) {
+ throw new Error("the whole desktop needs the risks accepted first");
+ }
+ const same = this.grants.find(
+ (grant) => grant.botId === botId && grant.scope === "desktop",
+ );
+ if (same) {
+ same.level = level;
+ same.acceptedRisksAt = this.fake.now;
+ } else {
+ this.grants.push({
+ id: this.fake.id("dsk"),
+ botId,
+ scope: "desktop",
+ appPath: null,
+ appName: null,
+ level,
+ realInput: false,
+ unattended: false,
+ acceptedRisksAt: this.fake.now,
+ createdAt: this.fake.now,
+ });
+ }
+ this.changed(botId);
+ return { botId, grants: this.of(botId) };
+ },
"desktop.awayUses": () => this.away,
"desktop.dismissAway": () => {
this.away = [];
diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts
index a1863f0a..f4393228 100644
--- a/app/src/lib/protocol.gen.ts
+++ b/app/src/lib/protocol.gen.ts
@@ -992,6 +992,16 @@ from: number, until: number,
*/
itemId: ChatItemId | null, };
+/**
+ * `desktop.grantWhole`: the owner gives the bot the whole desktop (spec
+ * 24.10), never asked for in the chat.
+ */
+export type DesktopWholeParams = { botId: BotId, level: DesktopLevel,
+/**
+ * The owner checked "I understand the risks"; without it, refused.
+ */
+acceptedRisks: boolean, };
+
export type ApprovalItem = { approvalId: ApprovalId, toolName: string, summary: string,
/**
* What the bot says the command is for, as in `ToolItem`. The bot
@@ -1676,6 +1686,7 @@ export interface RpcMethods {
"desktop.grants": { params: DesktopGrantsParams; result: Array };
"desktop.revoke": { params: DesktopGrantIdParams; result: BotDesktop };
"desktop.setOptions": { params: DesktopOptionsParams; result: BotDesktop };
+ "desktop.grantWhole": { params: DesktopWholeParams; result: BotDesktop };
"desktop.watch": { params: DesktopBotParams; result: DesktopView };
"desktop.unwatch": { params: undefined; result: null };
"desktop.stop": { params: DesktopBotParams; result: DesktopState };
diff --git a/crates/botloft-core/src/protocol/desktop.rs b/crates/botloft-core/src/protocol/desktop.rs
index 41fc4b15..9ce7052b 100644
--- a/crates/botloft-core/src/protocol/desktop.rs
+++ b/crates/botloft-core/src/protocol/desktop.rs
@@ -119,6 +119,18 @@ pub struct DesktopOptionsParams {
pub accepted_risks: Option,
}
+/// `desktop.grantWhole`: the owner gives the bot the whole desktop (spec
+/// 24.10), never asked for in the chat.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+#[cfg_attr(test, derive(ts_rs::TS))]
+pub struct DesktopWholeParams {
+ pub bot_id: BotId,
+ pub level: DesktopLevel,
+ /// The owner checked "I understand the risks"; without it, refused.
+ pub accepted_risks: bool,
+}
+
/// A bot used an app while the owner was away (spec 24.8): what the app
/// tells them when they are back, until they dismiss it. The result of
/// `desktop.awayUses` and the params of `desktop.away` are a list of these.
diff --git a/crates/botloft-core/src/protocol/export_desktop.rs b/crates/botloft-core/src/protocol/export_desktop.rs
index be663315..6931dc4b 100644
--- a/crates/botloft-core/src/protocol/export_desktop.rs
+++ b/crates/botloft-core/src/protocol/export_desktop.rs
@@ -21,6 +21,7 @@ pub(super) fn decls(out: &mut Out) {
out.decl::();
out.decl::();
out.decl::();
+ out.decl::();
}
pub(super) fn methods(out: &mut Out) {
@@ -39,6 +40,11 @@ pub(super) fn methods(out: &mut Out) {
&out.name::(),
&out.name::(),
);
+ out.method(
+ method::DESKTOP_GRANT_WHOLE,
+ &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/mod.rs b/crates/botloft-core/src/protocol/mod.rs
index 4e5fd62a..7d3c4022 100644
--- a/crates/botloft-core/src/protocol/mod.rs
+++ b/crates/botloft-core/src/protocol/mod.rs
@@ -127,6 +127,7 @@ pub mod method {
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_GRANT_WHOLE: &str = "desktop.grantWhole";
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 2899a502..248b79c2 100644
--- a/crates/botloft-store/src/desktop_grants.rs
+++ b/crates/botloft-store/src/desktop_grants.rs
@@ -69,6 +69,36 @@ impl Store {
)?)
}
+ /// Gives `bot` the whole desktop at `level` (spec 24.2), the owner
+ /// having accepted the risks at `now` (spec 24.10); a grant of it
+ /// already there takes the new level and keeps its options.
+ pub fn grant_desktop_whole(
+ &self,
+ bot: &BotId,
+ level: DesktopLevel,
+ now: i64,
+ ) -> Result {
+ self.conn.execute(
+ "INSERT INTO desktop_grants (id, bot_id, scope, level, accepted_risks_at, created_at) \
+ VALUES (?1, ?2, 'desktop', ?3, ?4, ?4) \
+ ON CONFLICT (bot_id, scope, lower(coalesce(app_path, ''))) DO UPDATE SET \
+ level = excluded.level, accepted_risks_at = excluded.accepted_risks_at",
+ params![
+ DesktopGrantId::generate().as_str(),
+ bot.as_str(),
+ level.as_str(),
+ now,
+ ],
+ )?;
+ Ok(self.conn.query_row(
+ &format!(
+ "SELECT {COLUMNS} FROM desktop_grants WHERE bot_id = ?1 AND scope = 'desktop'"
+ ),
+ [bot.as_str()],
+ from_row,
+ )?)
+ }
+
/// The grants of `bot`, oldest first.
pub fn desktop_grants(&self, bot: &BotId) -> Result> {
let mut statement = self.conn.prepare_cached(&format!(
@@ -235,6 +265,36 @@ mod tests {
assert_eq!(off.accepted_risks_at, Some(50));
}
+ #[test]
+ fn the_whole_desktop_is_one_grant_whose_level_the_owner_sets() {
+ let fx = Fixture::new();
+ let scout = &fx.bots[0].id;
+ let see = fx
+ .store
+ .grant_desktop_whole(scout, DesktopLevel::Act, 10)
+ .expect("whole");
+ assert_eq!(see.scope, DesktopScope::Desktop);
+ assert_eq!((see.app_path.clone(), see.app_name.clone()), (None, None));
+ assert_eq!(see.accepted_risks_at, Some(10));
+ fx.store
+ .set_desktop_real_input(&see.id, true)
+ .expect("real");
+ // Given again, it can go down and keeps its options.
+ let again = fx
+ .store
+ .grant_desktop_whole(scout, DesktopLevel::See, 20)
+ .expect("again");
+ assert_eq!(again.id, see.id);
+ assert_eq!(again.level, DesktopLevel::See);
+ assert!(again.real_input);
+ assert_eq!(again.accepted_risks_at, Some(20));
+ // An app grant is another one.
+ fx.store
+ .grant_desktop_app(scout, EXCEL, "Microsoft Excel", DesktopLevel::See, 30)
+ .expect("app");
+ assert_eq!(fx.store.desktop_grants(scout).expect("grants").len(), 2);
+ }
+
#[test]
fn a_grant_can_be_taken_away() {
let fx = Fixture::new();
diff --git a/crates/botloftd/src/platform/desktop/mod.rs b/crates/botloftd/src/platform/desktop/mod.rs
index 7cd6c548..92db33a0 100644
--- a/crates/botloftd/src/platform/desktop/mod.rs
+++ b/crates/botloftd/src/platform/desktop/mod.rs
@@ -7,6 +7,7 @@ mod blocked;
pub mod keys;
mod overlay;
mod read;
+mod screen;
// Only Windows takes pictures; the tests run everywhere.
#[cfg_attr(not(windows), allow(dead_code))]
mod shrink;
@@ -18,6 +19,7 @@ use std::path::PathBuf;
pub use blocked::{Never, never};
pub use overlay::{ScreenCursor, notice_hide, notice_show, outline_hide, outline_show};
pub use read::{Control, LINES_MAX, by_reference, reference, render};
+pub use screen::{cover, screen_picture};
/// The largest picture of a window the bot gets, in logical pixels (24.4).
pub const PICTURE_MAX: (u32, u32) = (1600, 1200);
diff --git a/crates/botloftd/src/platform/desktop/screen.rs b/crates/botloftd/src/platform/desktop/screen.rs
new file mode 100644
index 00000000..df6bfc24
--- /dev/null
+++ b/crates/botloftd/src/platform/desktop/screen.rs
@@ -0,0 +1,72 @@
+//! A picture of the owner's whole screen for a bot with the whole desktop
+//! (spec 24.4, D6): every window as the owner sees it, the ones that are
+//! never granted (spec 24.3) covered in black.
+
+use super::{DesktopError, Picture};
+
+/// Paints each of `covered` (left, top, width and height on the screen)
+/// black in `rgb`, a picture `width` × `height` of the screen from
+/// `origin`; what falls outside the picture is left out.
+pub fn cover(rgb: &mut [u8], width: u32, height: u32, origin: (i32, i32), covered: &[[i32; 4]]) {
+ let (width, height) = (i64::from(width), i64::from(height));
+ for &[left, top, across, down] in covered {
+ let from_x = (i64::from(left) - i64::from(origin.0)).clamp(0, width);
+ let to_x = (i64::from(left) + i64::from(across) - i64::from(origin.0)).clamp(0, width);
+ let from_y = (i64::from(top) - i64::from(origin.1)).clamp(0, height);
+ let to_y = (i64::from(top) + i64::from(down) - i64::from(origin.1)).clamp(0, height);
+ for row in from_y..to_y {
+ let start = ((row * width + from_x) * 3) as usize;
+ let end = ((row * width + to_x) * 3) as usize;
+ rgb[start..end].fill(0);
+ }
+ }
+}
+
+/// The whole screen, with every window never granted covered, in logical
+/// pixels up to [`super::PICTURE_MAX`].
+pub fn screen_picture() -> Result {
+ #[cfg(windows)]
+ {
+ use super::{QUALITY, never, shrink, windows};
+ let covered: Vec<[i32; 4]> = windows::every_shown()?
+ .iter()
+ .filter(|window| !window.minimized && never(window).is_some())
+ .filter_map(|window| windows::frame(window.id).ok())
+ .collect();
+ let (rgb, width, height, scale) = windows::capture_screen(&covered)?;
+ Ok(shrink::picture(
+ &rgb,
+ width,
+ height,
+ scale,
+ super::PICTURE_MAX,
+ QUALITY,
+ ))
+ }
+ #[cfg(not(windows))]
+ {
+ Err(DesktopError::Unavailable)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::cover;
+
+ #[test]
+ fn covered_windows_turn_black_and_only_inside_the_picture() {
+ // A 4 × 3 screen from (-2, 10), all white.
+ let mut rgb = vec![255u8; 4 * 3 * 3];
+ // A window at (-1, 11), 2 × 5: columns 1 and 2, rows 1 and 2.
+ cover(&mut rgb, 4, 3, (-2, 10), &[[-1, 11, 2, 5]]);
+ let black: Vec<(usize, usize)> = (0..3)
+ .flat_map(|row| (0..4).map(move |column| (row, column)))
+ .filter(|&(row, column)| rgb[(row * 4 + column) * 3] == 0)
+ .collect();
+ assert_eq!(black, vec![(1, 1), (1, 2), (2, 1), (2, 2)]);
+ // Off the screen, nothing.
+ let mut rgb = vec![255u8; 4 * 3 * 3];
+ cover(&mut rgb, 4, 3, (0, 0), &[[10, 10, 5, 5], [-9, -9, 2, 2]]);
+ assert!(rgb.iter().all(|&byte| byte == 255));
+ }
+}
diff --git a/crates/botloftd/src/platform/desktop/windows/capture.rs b/crates/botloftd/src/platform/desktop/windows/capture.rs
index ce880a47..e620ed26 100644
--- a/crates/botloftd/src/platform/desktop/windows/capture.rs
+++ b/crates/botloftd/src/platform/desktop/windows/capture.rs
@@ -28,10 +28,10 @@ const RENDER_FULL_CONTENT: PRINT_WINDOW_FLAGS = PRINT_WINDOW_FLAGS(2);
const BASE_DPI: f64 = 96.0;
/// Sizes in real pixels on this thread while it lives, then back.
-struct RealPixels(DPI_AWARENESS_CONTEXT);
+pub(super) struct RealPixels(DPI_AWARENESS_CONTEXT);
impl RealPixels {
- fn new() -> Self {
+ pub(super) fn new() -> Self {
// SAFETY: changes only this thread's DPI awareness, restored on drop.
Self(unsafe { SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) })
}
@@ -47,11 +47,11 @@ impl Drop for RealPixels {
}
/// Frees the drawing objects in the right order when it goes.
-struct Canvas {
- screen: HDC,
- memory: HDC,
- bitmap: HBITMAP,
- previous: HGDIOBJ,
+pub(super) struct Canvas {
+ pub(super) screen: HDC,
+ pub(super) memory: HDC,
+ pub(super) bitmap: HBITMAP,
+ pub(super) previous: HGDIOBJ,
}
impl Drop for Canvas {
@@ -66,7 +66,7 @@ impl Drop for Canvas {
}
}
-fn system(what: &str) -> DesktopError {
+pub(super) fn system(what: &str) -> DesktopError {
DesktopError::System(what.to_owned())
}
diff --git a/crates/botloftd/src/platform/desktop/windows/mod.rs b/crates/botloftd/src/platform/desktop/windows/mod.rs
index b740f640..210cb156 100644
--- a/crates/botloftd/src/platform/desktop/windows/mod.rs
+++ b/crates/botloftd/src/platform/desktop/windows/mod.rs
@@ -17,6 +17,7 @@ mod process;
mod real;
#[cfg(test)]
mod real_tests;
+mod screen;
mod stop_key;
#[cfg(test)]
mod test_window;
@@ -44,6 +45,7 @@ pub use input::owner_idle;
pub use notice::{hide as notice_hide, show as notice_show};
pub use outline::{hide as outline_hide, show as outline_show};
pub use real::{click as real_click, press as real_press, type_text as real_type};
+pub use screen::capture_screen;
pub use stop_key::on_stop_key;
pub use uia::read;
@@ -54,6 +56,17 @@ use super::{App, DesktopError, Window};
const FRAME_HOST: &str = "applicationframehost.exe";
pub fn list() -> Result, DesktopError> {
+ gather(false)
+}
+
+/// Every window shown on the screen, of other processes: with tool windows
+/// and windows without a title too, to cover the ones never granted in a
+/// picture of the whole screen (spec 24.4).
+pub fn every_shown() -> Result, DesktopError> {
+ gather(true)
+}
+
+fn gather(all: bool) -> Result, DesktopError> {
let mut handles: Vec = Vec::new();
// SAFETY: the callback only pushes into `handles`, which outlives the
// call.
@@ -67,11 +80,15 @@ pub fn list() -> Result, DesktopError> {
let mut apps: HashMap = HashMap::new();
let mut found = Vec::new();
for hwnd in handles {
- if !shown(hwnd) {
+ if !shown(hwnd, all) {
continue;
}
let title = text(hwnd);
- if title.trim().is_empty() {
+ if !all && title.trim().is_empty() {
+ continue;
+ }
+ // The daemon's own outline and notice stay out of every picture.
+ if all && process_id(hwnd) == std::process::id() {
continue;
}
let class = class_of(hwnd);
@@ -103,15 +120,15 @@ unsafe extern "system" fn collect(hwnd: HWND, found: LPARAM) -> BOOL {
}
/// A window the owner can see on the taskbar or the screen: shown, not a
-/// tool window, and not cloaked (Store apps kept hidden, other virtual
-/// desktops).
-fn shown(hwnd: HWND) -> bool {
+/// tool window (unless `all`), and not cloaked (Store apps kept hidden,
+/// other virtual desktops).
+fn shown(hwnd: HWND, all: bool) -> bool {
// SAFETY: plain queries on a window handle.
unsafe {
if !IsWindowVisible(hwnd).as_bool() {
return false;
}
- if GetWindowLongPtrW(hwnd, GWL_EXSTYLE) & WS_EX_TOOLWINDOW.0 as isize != 0 {
+ if !all && GetWindowLongPtrW(hwnd, GWL_EXSTYLE) & WS_EX_TOOLWINDOW.0 as isize != 0 {
return false;
}
let mut cloaked = 0u32;
diff --git a/crates/botloftd/src/platform/desktop/windows/notice.rs b/crates/botloftd/src/platform/desktop/windows/notice.rs
index 2559cd68..34643aec 100644
--- a/crates/botloftd/src/platform/desktop/windows/notice.rs
+++ b/crates/botloftd/src/platform/desktop/windows/notice.rs
@@ -22,9 +22,9 @@ use windows::Win32::UI::HiDpi::{
use windows::Win32::UI::WindowsAndMessaging::{
CreateWindowExW, DefWindowProcW, DispatchMessageW, GetMessageW, HWND_TOPMOST, LWA_COLORKEY,
MSG, PostMessageW, RegisterClassW, SW_HIDE, SW_SHOWNOACTIVATE, SWP_NOACTIVATE,
- SetLayeredWindowAttributes, SetWindowPos, ShowWindow, TranslateMessage, WM_APP, WM_PAINT,
- WNDCLASSW, WS_EX_LAYERED, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_EX_TOPMOST, WS_EX_TRANSPARENT,
- WS_POPUP,
+ SetLayeredWindowAttributes, SetWindowDisplayAffinity, SetWindowPos, ShowWindow,
+ TranslateMessage, WDA_EXCLUDEFROMCAPTURE, WM_APP, WM_PAINT, WNDCLASSW, WS_EX_LAYERED,
+ WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_EX_TOPMOST, WS_EX_TRANSPARENT, WS_POPUP,
};
use windows::core::{PCWSTR, w};
@@ -103,6 +103,8 @@ fn run(made: &Sender