Files
openclaw/apps/linux/src-tauri/src/pending_approvals.rs
T
Peter Steinberger da8a443a4d feat(linux): pending-approval notifications and a summon shortcut for the companion (#109322)
The companion now rings the doorbell for gateway approvals: it polls pending
node and device pairing requests over the existing CLI seam on the connected
watchdog cadence, fires one native notification per new request when the
window is unfocused, and shows a pending count in the tray status line.
Approval itself stays in the dashboard/CLI (owner boundary).

Adds a CmdOrCtrl+Shift+O summon shortcut with a tray toggle whose opt-out
persists as a marker file (no config schema). The X11-only global-hotkey
backend is gated off on native Wayland sessions — the feature is omitted
rather than half-registered through XWayland; a portal implementation can
follow.
2026-07-16 13:06:18 -07:00

205 lines
6.2 KiB
Rust

use crate::cli::OpenClawCli;
use serde::Deserialize;
use std::collections::HashSet;
use std::process::Output;
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
enum ApprovalKind {
Node,
Device,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PendingApproval {
kind: ApprovalKind,
request_id: String,
label: String,
}
impl PendingApproval {
pub fn notification_body(&self) -> String {
let kind = match self.kind {
ApprovalKind::Node => "Node",
ApprovalKind::Device => "Device",
};
format!(
"{kind} pairing request from {} — open the dashboard to approve",
self.label
)
}
}
#[derive(Default)]
pub struct PendingApprovalState {
visible: HashSet<String>,
}
pub struct PendingApprovalDiff {
pub new: Vec<PendingApproval>,
pub count: usize,
}
impl PendingApprovalState {
pub fn update(&mut self, current: &[PendingApproval]) -> PendingApprovalDiff {
let (new, visible) = diff_pending(&self.visible, current);
let count = visible.len();
self.visible = visible;
PendingApprovalDiff { new, count }
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct NodePendingRequest {
request_id: String,
node_id: String,
display_name: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct DevicePendingRequest {
request_id: String,
device_id: String,
display_name: Option<String>,
client_id: Option<String>,
}
#[derive(Deserialize)]
struct DevicePairingList {
#[serde(default)]
pending: Vec<DevicePendingRequest>,
}
pub fn fetch(cli: &OpenClawCli) -> Result<Vec<PendingApproval>, String> {
let (nodes, node_output) = cli
.json::<Vec<NodePendingRequest>, _, _>(["nodes", "pending", "--json"])
.map_err(|error| error.to_string())?;
require_success("nodes pending", &node_output)?;
let (devices, device_output) = cli
.json::<DevicePairingList, _, _>(["devices", "list", "--json"])
.map_err(|error| error.to_string())?;
require_success("devices list", &device_output)?;
let mut pending = Vec::with_capacity(nodes.len() + devices.pending.len());
pending.extend(nodes.into_iter().map(|request| PendingApproval {
kind: ApprovalKind::Node,
request_id: request.request_id,
label: preferred_label([request.display_name.as_deref(), Some(&request.node_id)]),
}));
pending.extend(devices.pending.into_iter().map(|request| PendingApproval {
kind: ApprovalKind::Device,
request_id: request.request_id,
label: preferred_label([
request.display_name.as_deref(),
request.client_id.as_deref(),
Some(&request.device_id),
]),
}));
Ok(pending)
}
fn require_success(command: &str, output: &Output) -> Result<(), String> {
if output.status.success() {
Ok(())
} else {
Err(format!("openclaw {command} exited with {}", output.status))
}
}
fn preferred_label<'a>(candidates: impl IntoIterator<Item = Option<&'a str>>) -> String {
candidates
.into_iter()
.flatten()
.map(str::trim)
.find(|value| !value.is_empty())
.unwrap_or("unknown")
.to_string()
}
// Only successful snapshots replace `visible`; failed polls must leave dedupe state intact.
fn diff_pending(
previous: &HashSet<String>,
current: &[PendingApproval],
) -> (Vec<PendingApproval>, HashSet<String>) {
let mut visible = HashSet::with_capacity(current.len());
let mut new = Vec::new();
for request in current {
if visible.insert(request.request_id.clone()) && !previous.contains(&request.request_id) {
new.push(request.clone());
}
}
(new, visible)
}
#[cfg(test)]
mod tests {
use super::{diff_pending, ApprovalKind, PendingApproval, PendingApprovalState};
use std::collections::HashSet;
fn request(kind: ApprovalKind, id: &str, label: &str) -> PendingApproval {
PendingApproval {
kind,
request_id: id.to_string(),
label: label.to_string(),
}
}
#[test]
fn diff_reports_new_ids_once_and_deduplicates_a_snapshot() {
let current = vec![
request(ApprovalKind::Node, "node-1", "Kitchen Mac"),
request(ApprovalKind::Node, "node-1", "Kitchen Mac"),
request(ApprovalKind::Device, "device-1", "Browser"),
];
let (new, visible) = diff_pending(&HashSet::new(), &current);
assert_eq!(new, vec![current[0].clone(), current[2].clone()]);
assert_eq!(visible.len(), 2);
}
#[test]
fn state_deduplicates_retained_ids_and_forgets_removed_ids() {
let node = request(ApprovalKind::Node, "request-1", "Kitchen Mac");
let device = request(ApprovalKind::Device, "request-2", "Browser");
let mut state = PendingApprovalState::default();
let first = state.update(&[node.clone(), device.clone()]);
assert_eq!(first.new, vec![node.clone(), device.clone()]);
assert_eq!(first.count, 2);
let retained = state.update(std::slice::from_ref(&device));
assert!(retained.new.is_empty());
assert_eq!(retained.count, 1);
let returned = state.update(&[node.clone(), device]);
assert_eq!(returned.new, vec![node]);
assert_eq!(returned.count, 2);
}
#[test]
fn same_id_is_deduplicated_across_request_kinds() {
let node = request(ApprovalKind::Node, "same-id", "Node");
let device = request(ApprovalKind::Device, "same-id", "Browser");
let (new, visible) = diff_pending(&HashSet::new(), &[node.clone(), device]);
assert_eq!(new, vec![node]);
assert_eq!(visible.len(), 1);
}
#[test]
fn notification_copy_names_request_kind_and_source() {
assert_eq!(
request(ApprovalKind::Node, "request-1", "Kitchen Mac").notification_body(),
"Node pairing request from Kitchen Mac — open the dashboard to approve"
);
assert_eq!(
request(ApprovalKind::Device, "request-2", "Browser").notification_body(),
"Device pairing request from Browser — open the dashboard to approve"
);
}
}