mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 23:31:34 +00:00
Two geometry defects found while stress-testing the companion. Positioning mixed coordinate spaces. `work_area()` is physical pixels of the monitor Quick Chat is moving to, but `inner_size()` is physical pixels at the scale of the monitor it is on now. A 640pt window on a 2x display reports 1280px, so invoking it on a 1x 1920px display centred using 1280 and landed it 320px left of centre. Re-express the window size in the target monitor's scale first; equal scales give a ratio of 1, so single-monitor setups are untouched to the pixel. Widget growth resized without re-anchoring. `quickchat_set_expanded` resizes and then repositions, but the widget path only resized, keeping the old top edge. Growing from 360pt to 440pt in a 440pt work area left the bottom 80pt off-screen and unreachable. `resize_window_if_needed` now reports whether it actually changed the height so the caller re-anchors only on a real resize, rather than moving the window when nothing changed.
903 lines
31 KiB
Rust
903 lines
31 KiB
Rust
use crate::gateway_ws::GatewayClient;
|
|
use crate::quickchat::{position_quickchat, QuickChatState, QUICKCHAT_LABEL};
|
|
use serde::Deserialize;
|
|
use sha2::{Digest, Sha256};
|
|
use std::collections::HashSet;
|
|
use std::net::IpAddr;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::{Arc, Mutex};
|
|
use tauri::webview::{NewWindowResponse, WebviewBuilder};
|
|
use tauri::{
|
|
AppHandle, LogicalPosition, LogicalSize, Manager, State, Url, Webview, WebviewUrl, Window,
|
|
};
|
|
use tokio::sync::Mutex as AsyncMutex;
|
|
|
|
const QUICKCHAT_WIDTH: f64 = 640.0;
|
|
const QUICKCHAT_COMPACT_WINDOW_HEIGHT: f64 = 92.0;
|
|
const QUICKCHAT_TEXT_WINDOW_HEIGHT: f64 = 360.0;
|
|
const QUICKCHAT_WIDGET_WINDOW_HEIGHT: f64 = 440.0;
|
|
const QUICKCHAT_WIDGET_HEIGHT: f64 = 160.0;
|
|
const QUICKCHAT_WIDGET_LABEL_PREFIX: &str = "quickchat-widget-";
|
|
const QUICKCHAT_WIDGET_MAX_COUNT: usize = 32;
|
|
const QUICKCHAT_WIDGET_MAX_URL_BYTES: usize = 4096;
|
|
|
|
#[derive(Clone, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct QuickChatWidgetLayout {
|
|
key: String,
|
|
url: String,
|
|
sandbox: String,
|
|
x: f64,
|
|
y: f64,
|
|
width: f64,
|
|
height: f64,
|
|
visible: bool,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct RendererSession {
|
|
id: String,
|
|
epoch: u64,
|
|
active_generation: u64,
|
|
closed_generation: u64,
|
|
hidden: bool,
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
pub struct QuickChatWidgetState {
|
|
views: Arc<Mutex<HashSet<String>>>,
|
|
sync: Arc<AsyncMutex<()>>,
|
|
active_session: Arc<Mutex<Option<RendererSession>>>,
|
|
}
|
|
|
|
fn quickchat_window_height(has_widgets: bool, expanded: bool) -> f64 {
|
|
if has_widgets {
|
|
QUICKCHAT_WIDGET_WINDOW_HEIGHT
|
|
} else if expanded {
|
|
QUICKCHAT_TEXT_WINDOW_HEIGHT
|
|
} else {
|
|
QUICKCHAT_COMPACT_WINDOW_HEIGHT
|
|
}
|
|
}
|
|
|
|
/// Returns whether the window was actually resized, so the caller can re-anchor
|
|
/// it only when its height really changed.
|
|
fn resize_window_if_needed(window: &Window, height: f64) -> Result<bool, String> {
|
|
let scale = window
|
|
.scale_factor()
|
|
.map_err(|error| format!("Could not read Quick Chat scale: {error}"))?;
|
|
let current = window
|
|
.inner_size()
|
|
.map_err(|error| format!("Could not read Quick Chat size: {error}"))?;
|
|
let current_height = f64::from(current.height) / scale;
|
|
if (current_height - height).abs() <= 0.5 {
|
|
return Ok(false);
|
|
}
|
|
window
|
|
.set_size(LogicalSize::new(QUICKCHAT_WIDTH, height))
|
|
.map_err(|error| format!("Could not resize Quick Chat for widgets: {error}"))?;
|
|
Ok(true)
|
|
}
|
|
|
|
impl QuickChatWidgetState {
|
|
fn validate_session_id(session_id: &str) -> Result<(), String> {
|
|
if session_id.trim().is_empty() || session_id.len() > 128 {
|
|
Err("Quick Chat renderer session is invalid.".to_string())
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn session_allows_sync(
|
|
active: &RendererSession,
|
|
session_id: &str,
|
|
renderer_epoch: u64,
|
|
generation: u64,
|
|
) -> bool {
|
|
active.id == session_id
|
|
&& active.epoch == renderer_epoch
|
|
&& !active.hidden
|
|
&& generation > active.closed_generation
|
|
}
|
|
|
|
fn view_labels(&self) -> Result<HashSet<String>, String> {
|
|
self.views
|
|
.lock()
|
|
.map_err(|_| "Quick Chat widget state is unavailable.".to_string())
|
|
.map(|views| views.clone())
|
|
}
|
|
|
|
fn store_view_labels(&self, labels: HashSet<String>) -> Result<(), String> {
|
|
*self
|
|
.views
|
|
.lock()
|
|
.map_err(|_| "Quick Chat widget state is unavailable.".to_string())? = labels;
|
|
Ok(())
|
|
}
|
|
|
|
fn close_views(
|
|
app: &AppHandle,
|
|
labels: &HashSet<String>,
|
|
parent_hidden: bool,
|
|
) -> (HashSet<String>, Option<String>) {
|
|
let mut retained = HashSet::new();
|
|
let mut first_error = None;
|
|
for label in labels {
|
|
let Some(webview) = app.get_webview(label) else {
|
|
continue;
|
|
};
|
|
if let Err(error) = webview.hide() {
|
|
retained.insert(label.clone());
|
|
if !parent_hidden {
|
|
first_error.get_or_insert_with(|| {
|
|
format!("Could not hide stale Quick Chat widget {label}: {error}")
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
if webview.close().is_err() {
|
|
// A hidden child is safe to retain and retry later; only hide failure
|
|
// can leave stale content visible and must abort the transition.
|
|
retained.insert(label.clone());
|
|
}
|
|
}
|
|
(retained, first_error)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn activate_session(
|
|
&self,
|
|
session_id: &str,
|
|
renderer_epoch: u64,
|
|
) -> Result<(bool, bool), String> {
|
|
Self::validate_session_id(session_id)?;
|
|
if renderer_epoch == 0 {
|
|
return Err("Quick Chat renderer epoch is invalid.".to_string());
|
|
}
|
|
let mut active = self
|
|
.active_session
|
|
.lock()
|
|
.map_err(|_| "Quick Chat renderer session is unavailable.".to_string())?;
|
|
if let Some(current) = active.as_ref() {
|
|
if current.id == session_id && current.epoch == renderer_epoch {
|
|
return Ok((true, false));
|
|
}
|
|
if current.epoch >= renderer_epoch {
|
|
return Ok((false, false));
|
|
}
|
|
}
|
|
*active = Some(RendererSession {
|
|
id: session_id.to_string(),
|
|
epoch: renderer_epoch,
|
|
active_generation: 0,
|
|
closed_generation: 0,
|
|
hidden: true,
|
|
});
|
|
Ok((true, true))
|
|
}
|
|
|
|
fn session_snapshot(
|
|
&self,
|
|
session_id: &str,
|
|
renderer_epoch: u64,
|
|
) -> Result<Option<(u64, u64, bool)>, String> {
|
|
self.active_session
|
|
.lock()
|
|
.map_err(|_| "Quick Chat renderer session is unavailable.".to_string())
|
|
.map(|active| {
|
|
active.as_ref().and_then(|active| {
|
|
(active.id == session_id && active.epoch == renderer_epoch).then_some((
|
|
active.active_generation,
|
|
active.closed_generation,
|
|
active.hidden,
|
|
))
|
|
})
|
|
})
|
|
}
|
|
|
|
pub async fn start_session(
|
|
&self,
|
|
app: &AppHandle,
|
|
session_id: &str,
|
|
renderer_epoch: u64,
|
|
) -> Result<bool, String> {
|
|
Self::validate_session_id(session_id)?;
|
|
if renderer_epoch == 0 {
|
|
return Err("Quick Chat renderer epoch is invalid.".to_string());
|
|
}
|
|
let _sync = self.sync.lock().await;
|
|
{
|
|
let active = self
|
|
.active_session
|
|
.lock()
|
|
.map_err(|_| "Quick Chat renderer session is unavailable.".to_string())?;
|
|
if let Some(current) = active.as_ref() {
|
|
if current.id == session_id && current.epoch == renderer_epoch {
|
|
return Ok(true);
|
|
}
|
|
if current.epoch >= renderer_epoch {
|
|
return Ok(false);
|
|
}
|
|
}
|
|
}
|
|
let labels = self.view_labels()?;
|
|
if !labels.is_empty() {
|
|
app.get_webview_window(QUICKCHAT_LABEL)
|
|
.ok_or_else(|| "Quick Chat window is unavailable.".to_string())?
|
|
.hide()
|
|
.map_err(|error| {
|
|
format!("Could not hide Quick Chat before renderer cleanup: {error}")
|
|
})?;
|
|
}
|
|
let (retained, cleanup_error) = Self::close_views(app, &labels, true);
|
|
self.store_view_labels(retained)?;
|
|
if let Some(error) = cleanup_error {
|
|
return Err(error);
|
|
}
|
|
*self
|
|
.active_session
|
|
.lock()
|
|
.map_err(|_| "Quick Chat renderer session is unavailable.".to_string())? =
|
|
Some(RendererSession {
|
|
id: session_id.to_string(),
|
|
epoch: renderer_epoch,
|
|
active_generation: 0,
|
|
closed_generation: 0,
|
|
hidden: true,
|
|
});
|
|
Ok(true)
|
|
}
|
|
|
|
pub async fn activate(
|
|
&self,
|
|
window: &Window,
|
|
session_id: &str,
|
|
renderer_epoch: u64,
|
|
generation: u64,
|
|
hide_requested: &AtomicBool,
|
|
) -> Result<bool, String> {
|
|
Self::validate_session_id(session_id)?;
|
|
let _sync = self.sync.lock().await;
|
|
{
|
|
let active = self
|
|
.active_session
|
|
.lock()
|
|
.map_err(|_| "Quick Chat renderer session is unavailable.".to_string())?;
|
|
let Some(active) = active.as_ref() else {
|
|
return Ok(false);
|
|
};
|
|
if active.id != session_id
|
|
|| active.epoch != renderer_epoch
|
|
|| generation < active.active_generation
|
|
{
|
|
return Ok(false);
|
|
}
|
|
}
|
|
let labels = self.view_labels()?;
|
|
let (retained, cleanup_error) = Self::close_views(window.app_handle(), &labels, false);
|
|
self.store_view_labels(retained)?;
|
|
if let Some(error) = cleanup_error {
|
|
return Err(error);
|
|
}
|
|
window
|
|
.show()
|
|
.map_err(|error| format!("Could not show Quick Chat: {error}"))?;
|
|
{
|
|
let mut active = self
|
|
.active_session
|
|
.lock()
|
|
.map_err(|_| "Quick Chat renderer session is unavailable.".to_string())?;
|
|
let active = active.as_mut().expect("renderer session held by sync lock");
|
|
active.active_generation = generation;
|
|
active.hidden = false;
|
|
}
|
|
hide_requested.store(false, Ordering::SeqCst);
|
|
Ok(true)
|
|
}
|
|
|
|
pub async fn hide(
|
|
&self,
|
|
app: &AppHandle,
|
|
window: &Window,
|
|
session_id: &str,
|
|
renderer_epoch: u64,
|
|
generation: u64,
|
|
hide_requested: &AtomicBool,
|
|
) -> Result<bool, String> {
|
|
Self::validate_session_id(session_id)?;
|
|
let _sync = self.sync.lock().await;
|
|
{
|
|
let active = self
|
|
.active_session
|
|
.lock()
|
|
.map_err(|_| "Quick Chat renderer session is unavailable.".to_string())?;
|
|
let Some(active) = active.as_ref() else {
|
|
return Ok(false);
|
|
};
|
|
if active.id != session_id
|
|
|| active.epoch != renderer_epoch
|
|
|| generation < active.active_generation
|
|
{
|
|
return Ok(false);
|
|
}
|
|
}
|
|
window
|
|
.hide()
|
|
.map_err(|error| format!("Could not hide Quick Chat: {error}"))?;
|
|
let labels = self.view_labels()?;
|
|
let (retained, _) = Self::close_views(app, &labels, true);
|
|
self.store_view_labels(retained)?;
|
|
{
|
|
let mut active = self
|
|
.active_session
|
|
.lock()
|
|
.map_err(|_| "Quick Chat renderer session is unavailable.".to_string())?;
|
|
let active = active.as_mut().expect("renderer session held by sync lock");
|
|
active.active_generation = generation;
|
|
active.hidden = true;
|
|
active.closed_generation = active.closed_generation.max(generation);
|
|
}
|
|
hide_requested.store(true, Ordering::SeqCst);
|
|
let _ = window.set_size(LogicalSize::new(
|
|
QUICKCHAT_WIDTH,
|
|
QUICKCHAT_COMPACT_WINDOW_HEIGHT,
|
|
));
|
|
Ok(true)
|
|
}
|
|
|
|
async fn sync(
|
|
&self,
|
|
webview: &Webview,
|
|
app: &AppHandle,
|
|
widgets: Vec<QuickChatWidgetLayout>,
|
|
has_widgets: bool,
|
|
expanded: bool,
|
|
session_id: &str,
|
|
renderer_epoch: u64,
|
|
generation: u64,
|
|
) -> Result<(), String> {
|
|
Self::validate_session_id(session_id)?;
|
|
let _sync = self.sync.lock().await;
|
|
{
|
|
let active = self
|
|
.active_session
|
|
.lock()
|
|
.map_err(|_| "Quick Chat renderer session is unavailable.".to_string())?;
|
|
let Some(active) = active.as_ref() else {
|
|
return Ok(());
|
|
};
|
|
if !Self::session_allows_sync(active, session_id, renderer_epoch, generation) {
|
|
return Ok(());
|
|
}
|
|
}
|
|
let window = webview.window();
|
|
if !has_widgets && !widgets.is_empty() {
|
|
return Err("Quick Chat received widget layouts without widget content.".to_string());
|
|
}
|
|
if widgets.len() > QUICKCHAT_WIDGET_MAX_COUNT {
|
|
return Err("Quick Chat received too many widgets.".to_string());
|
|
}
|
|
let mut keys = HashSet::new();
|
|
let mut visible_count = 0;
|
|
let mut prepared = Vec::with_capacity(widgets.len());
|
|
for widget in widgets {
|
|
if !keys.insert(widget.key.clone()) {
|
|
return Err("Quick Chat widget keys must be unique.".to_string());
|
|
}
|
|
visible_count += usize::from(widget.visible);
|
|
let url = validate_widget_layout(&widget)?;
|
|
let label = widget_view_label(&widget);
|
|
prepared.push((widget, label, url));
|
|
}
|
|
if visible_count > 1 {
|
|
return Err("Quick Chat can show only one widget at a time.".to_string());
|
|
}
|
|
let current = self
|
|
.views
|
|
.lock()
|
|
.map_err(|_| "Quick Chat widget state is unavailable.".to_string())?
|
|
.clone();
|
|
let mut desired = HashSet::new();
|
|
for (_, label, _) in &prepared {
|
|
if !desired.insert(label.clone()) {
|
|
return Err("Quick Chat widget identities must be unique.".to_string());
|
|
}
|
|
}
|
|
|
|
let cleanup_created = |labels: &HashSet<String>| {
|
|
for label in labels {
|
|
if let Some(webview) = app.get_webview(label) {
|
|
let _ = webview.close();
|
|
}
|
|
}
|
|
};
|
|
let parent = window.clone();
|
|
let mut created = HashSet::new();
|
|
let mut reconciled = Vec::with_capacity(prepared.len());
|
|
for (widget, label, url) in prepared {
|
|
let position = LogicalPosition::new(widget.x, widget.y);
|
|
let size = LogicalSize::new(widget.width, widget.height);
|
|
let existing = current
|
|
.contains(&label)
|
|
.then(|| app.get_webview(&label))
|
|
.flatten();
|
|
let webview = match existing {
|
|
Some(webview) => webview,
|
|
None => {
|
|
if let Some(orphan) = app.get_webview(&label) {
|
|
let _ = orphan.close();
|
|
}
|
|
let allowed_url = url.clone();
|
|
// Widget labels intentionally match no Tauri capability. Linux cannot isolate
|
|
// iframe IPC, so agent-authored scripts must stay in separate child WebViews.
|
|
let mut builder = WebviewBuilder::new(label.clone(), WebviewUrl::External(url))
|
|
.incognito(true)
|
|
.transparent(true)
|
|
.on_navigation(move |candidate| {
|
|
same_widget_document(candidate, &allowed_url)
|
|
})
|
|
.on_new_window(|_, _| NewWindowResponse::Deny);
|
|
if widget.sandbox == "strict" {
|
|
builder = builder.disable_javascript();
|
|
}
|
|
match parent.add_child(builder, position, size) {
|
|
Ok(webview) => {
|
|
created.insert(label.clone());
|
|
webview
|
|
}
|
|
Err(error) => {
|
|
cleanup_created(&created);
|
|
return Err(format!("Could not create Quick Chat widget: {error}"));
|
|
}
|
|
}
|
|
}
|
|
};
|
|
if let Err(error) = webview.set_position(position) {
|
|
cleanup_created(&created);
|
|
return Err(format!("Could not position Quick Chat widget: {error}"));
|
|
}
|
|
if let Err(error) = webview.set_size(size) {
|
|
cleanup_created(&created);
|
|
return Err(format!("Could not resize Quick Chat widget: {error}"));
|
|
}
|
|
reconciled.push((widget.visible, webview));
|
|
}
|
|
|
|
let mut committed = desired.clone();
|
|
for label in current.difference(&desired) {
|
|
if let Some(webview) = app.get_webview(label) {
|
|
if let Err(error) = webview.hide() {
|
|
cleanup_created(&created);
|
|
return Err(format!(
|
|
"Could not hide obsolete Quick Chat widget: {error}"
|
|
));
|
|
}
|
|
if webview.close().is_err() {
|
|
committed.insert(label.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
for (visible, webview) in &reconciled {
|
|
let result = if *visible {
|
|
webview.show()
|
|
} else {
|
|
webview.hide()
|
|
};
|
|
if let Err(error) = result {
|
|
cleanup_created(&created);
|
|
return Err(format!(
|
|
"Could not update Quick Chat widget visibility: {error}"
|
|
));
|
|
}
|
|
}
|
|
match resize_window_if_needed(&window, quickchat_window_height(has_widgets, expanded)) {
|
|
// Growing for widgets has to re-anchor the window the same way the
|
|
// expand path does. Resizing alone keeps the old top edge, so a
|
|
// taller window can hang off the bottom of the work area.
|
|
Ok(true) => {
|
|
if let Err(error) = position_quickchat(window.app_handle(), &window) {
|
|
cleanup_created(&created);
|
|
return Err(error);
|
|
}
|
|
}
|
|
Ok(false) => {}
|
|
Err(error) => {
|
|
cleanup_created(&created);
|
|
return Err(error);
|
|
}
|
|
}
|
|
|
|
*self.views.lock().map_err(|_| {
|
|
cleanup_created(&created);
|
|
"Quick Chat widget state is unavailable.".to_string()
|
|
})? = committed;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn quickchat_refresh_widget_surface(
|
|
webview: Webview,
|
|
gateway: State<'_, GatewayClient>,
|
|
) -> Result<Option<String>, String> {
|
|
if webview.label() != QUICKCHAT_LABEL || webview.window().label() != QUICKCHAT_LABEL {
|
|
return Err("Quick Chat command is available only to the Quick Chat webview.".to_string());
|
|
}
|
|
gateway.refresh_canvas_surface().await
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn quickchat_sync_widgets(
|
|
webview: Webview,
|
|
app: AppHandle,
|
|
state: State<'_, QuickChatState>,
|
|
widgets: Vec<QuickChatWidgetLayout>,
|
|
has_widgets: bool,
|
|
expanded: bool,
|
|
session_id: String,
|
|
renderer_epoch: u64,
|
|
generation: u64,
|
|
) -> Result<(), String> {
|
|
if webview.label() != QUICKCHAT_LABEL || webview.window().label() != QUICKCHAT_LABEL {
|
|
return Err("Quick Chat command is available only to the Quick Chat webview.".to_string());
|
|
}
|
|
state
|
|
.widget_state()
|
|
.sync(
|
|
&webview,
|
|
&app,
|
|
widgets,
|
|
has_widgets,
|
|
expanded,
|
|
&session_id,
|
|
renderer_epoch,
|
|
generation,
|
|
)
|
|
.await
|
|
}
|
|
|
|
fn percent_decode_once(raw: &str) -> Option<String> {
|
|
let bytes = raw.as_bytes();
|
|
let mut decoded = Vec::with_capacity(bytes.len());
|
|
let mut index = 0;
|
|
while index < bytes.len() {
|
|
if bytes[index] != b'%' {
|
|
decoded.push(bytes[index]);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if index + 2 >= bytes.len() {
|
|
return None;
|
|
}
|
|
let byte = u8::from_str_radix(&raw[index + 1..index + 3], 16).ok()?;
|
|
decoded.push(byte);
|
|
index += 3;
|
|
}
|
|
String::from_utf8(decoded).ok()
|
|
}
|
|
|
|
fn percent_decode_repeatedly(raw: &str) -> Option<String> {
|
|
let mut value = raw.to_string();
|
|
for _ in 0..8 {
|
|
let decoded = percent_decode_once(&value)?;
|
|
if decoded == value {
|
|
return Some(decoded);
|
|
}
|
|
value = decoded;
|
|
}
|
|
None
|
|
}
|
|
|
|
fn has_url_userinfo(url: &Url) -> bool {
|
|
url.as_str()
|
|
.split_once("://")
|
|
.map(|(_, suffix)| {
|
|
suffix
|
|
.split(['/', '?', '#'])
|
|
.next()
|
|
.is_some_and(|authority| authority.contains('@'))
|
|
})
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn has_secure_widget_transport(url: &Url) -> bool {
|
|
if url.scheme() == "https" {
|
|
return true;
|
|
}
|
|
if url.scheme() != "http" {
|
|
return false;
|
|
}
|
|
let Some(host) = url
|
|
.host_str()
|
|
.map(|host| host.trim_matches(['[', ']']).to_ascii_lowercase())
|
|
else {
|
|
return false;
|
|
};
|
|
host == "localhost"
|
|
|| host.ends_with(".localhost")
|
|
|| host
|
|
.parse::<IpAddr>()
|
|
.is_ok_and(|address| address.is_loopback())
|
|
}
|
|
|
|
fn validate_widget_url(raw: &str) -> Result<Url, String> {
|
|
if raw.len() > QUICKCHAT_WIDGET_MAX_URL_BYTES {
|
|
return Err("Quick Chat widget URL is too long.".to_string());
|
|
}
|
|
let url =
|
|
Url::parse(raw.trim()).map_err(|_| "Quick Chat widget URL is invalid.".to_string())?;
|
|
if !has_secure_widget_transport(&url) || has_url_userinfo(&url) || url.host_str().is_none() {
|
|
return Err("Quick Chat widget URL is not a secure HTTP capability URL.".to_string());
|
|
}
|
|
let encoded_segments = url
|
|
.path()
|
|
.split('/')
|
|
.skip(1)
|
|
.map(ToOwned::to_owned)
|
|
.collect::<Vec<_>>();
|
|
if encoded_segments.iter().any(|segment| segment.is_empty()) {
|
|
return Err("Quick Chat widget URL has an invalid path.".to_string());
|
|
}
|
|
let segments = encoded_segments
|
|
.iter()
|
|
.map(|segment| {
|
|
let decoded = percent_decode_repeatedly(segment)
|
|
.ok_or_else(|| "Quick Chat widget URL has invalid encoding.".to_string())?;
|
|
if decoded == "." || decoded == ".." || decoded.contains('/') || decoded.contains('\\')
|
|
{
|
|
return Err("Quick Chat widget URL has an unsafe path.".to_string());
|
|
}
|
|
Ok(decoded)
|
|
})
|
|
.collect::<Result<Vec<_>, String>>()?;
|
|
let Some(capability_index) = segments
|
|
.windows(2)
|
|
.rposition(|pair| pair == ["__openclaw__", "cap"])
|
|
else {
|
|
return Err("Quick Chat widget URL is missing its capability scope.".to_string());
|
|
};
|
|
let document_index = capability_index + 3;
|
|
if segments
|
|
.get(capability_index + 2)
|
|
.is_none_or(String::is_empty)
|
|
|| segments.get(document_index).map(String::as_str) != Some("__openclaw__")
|
|
|| segments.get(document_index + 1).map(String::as_str) != Some("canvas")
|
|
|| segments.get(document_index + 2).map(String::as_str) != Some("documents")
|
|
|| segments.len() < document_index + 5
|
|
{
|
|
return Err("Quick Chat widget URL is outside the Canvas document scope.".to_string());
|
|
}
|
|
Ok(url)
|
|
}
|
|
|
|
fn validate_widget_layout(widget: &QuickChatWidgetLayout) -> Result<Url, String> {
|
|
if widget.key.trim().is_empty() || widget.key.len() > 256 {
|
|
return Err("Quick Chat widget key is invalid.".to_string());
|
|
}
|
|
if widget.sandbox != "scripts" && widget.sandbox != "strict" {
|
|
return Err("Quick Chat widget sandbox is invalid.".to_string());
|
|
}
|
|
if !widget.x.is_finite()
|
|
|| !widget.y.is_finite()
|
|
|| !widget.width.is_finite()
|
|
|| !widget.height.is_finite()
|
|
|| widget.x < 0.0
|
|
|| widget.y < 0.0
|
|
|| widget.width < 1.0
|
|
|| (widget.height - QUICKCHAT_WIDGET_HEIGHT).abs() > 0.5
|
|
|| widget.x + widget.width > QUICKCHAT_WIDTH + 1.0
|
|
|| widget.y + widget.height > QUICKCHAT_WIDGET_WINDOW_HEIGHT + 1.0
|
|
{
|
|
return Err("Quick Chat widget bounds are invalid.".to_string());
|
|
}
|
|
validate_widget_url(&widget.url)
|
|
}
|
|
|
|
fn widget_view_label(widget: &QuickChatWidgetLayout) -> String {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(widget.key.as_bytes());
|
|
hasher.update([0]);
|
|
hasher.update(widget.url.as_bytes());
|
|
hasher.update([0]);
|
|
hasher.update(widget.sandbox.as_bytes());
|
|
let digest = hasher.finalize();
|
|
let suffix = digest[..8]
|
|
.iter()
|
|
.map(|byte| format!("{byte:02x}"))
|
|
.collect::<String>();
|
|
format!("{QUICKCHAT_WIDGET_LABEL_PREFIX}{suffix}")
|
|
}
|
|
|
|
fn same_widget_document(candidate: &Url, allowed: &Url) -> bool {
|
|
candidate.scheme() == allowed.scheme()
|
|
&& !has_url_userinfo(candidate)
|
|
&& candidate.host_str() == allowed.host_str()
|
|
&& candidate.port_or_known_default() == allowed.port_or_known_default()
|
|
&& candidate.path() == allowed.path()
|
|
&& candidate.query() == allowed.query()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::collections::HashMap;
|
|
|
|
fn test_widget(key: &str, url: &str, sandbox: &str) -> QuickChatWidgetLayout {
|
|
QuickChatWidgetLayout {
|
|
key: key.to_string(),
|
|
url: url.to_string(),
|
|
sandbox: sandbox.to_string(),
|
|
x: 52.0,
|
|
y: 174.0,
|
|
width: 540.0,
|
|
height: QUICKCHAT_WIDGET_HEIGHT,
|
|
visible: true,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn new_renderer_session_resets_closed_generations() {
|
|
let state = QuickChatWidgetState::default();
|
|
assert_eq!(
|
|
state
|
|
.activate_session("renderer-a", 100)
|
|
.expect("activate first"),
|
|
(true, true)
|
|
);
|
|
{
|
|
let mut active = state.active_session.lock().expect("session state");
|
|
let active = active.as_mut().expect("active renderer");
|
|
active.active_generation = 8;
|
|
active.hidden = true;
|
|
}
|
|
assert_eq!(
|
|
state
|
|
.session_snapshot("renderer-a", 100)
|
|
.expect("first renderer state"),
|
|
Some((8, 0, true))
|
|
);
|
|
assert_eq!(
|
|
state
|
|
.activate_session("stale-renderer", 99)
|
|
.expect("reject stale"),
|
|
(false, false)
|
|
);
|
|
assert_eq!(
|
|
state
|
|
.activate_session("renderer-b", 101)
|
|
.expect("activate second"),
|
|
(true, true)
|
|
);
|
|
assert_eq!(
|
|
state
|
|
.session_snapshot("renderer-a", 100)
|
|
.expect("old session"),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
state
|
|
.session_snapshot("renderer-b", 101)
|
|
.expect("new session"),
|
|
Some((0, 0, true))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn hidden_renderer_rejects_widget_syncs() {
|
|
let state = QuickChatWidgetState::default();
|
|
state
|
|
.activate_session("renderer", 100)
|
|
.expect("activate renderer");
|
|
let active = state.active_session.lock().expect("session state");
|
|
let active = active.as_ref().expect("active renderer");
|
|
assert!(active.hidden);
|
|
assert!(!QuickChatWidgetState::session_allows_sync(
|
|
active, "renderer", 100, 1
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn semantic_widget_state_owns_window_height() {
|
|
assert_eq!(
|
|
quickchat_window_height(false, false),
|
|
QUICKCHAT_COMPACT_WINDOW_HEIGHT
|
|
);
|
|
assert_eq!(
|
|
quickchat_window_height(false, true),
|
|
QUICKCHAT_TEXT_WINDOW_HEIGHT
|
|
);
|
|
assert_eq!(
|
|
quickchat_window_height(true, false),
|
|
QUICKCHAT_WIDGET_WINDOW_HEIGHT
|
|
);
|
|
assert_eq!(
|
|
quickchat_window_height(true, true),
|
|
QUICKCHAT_WIDGET_WINDOW_HEIGHT
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn layout_requires_a_scoped_canvas_document() {
|
|
let valid = test_widget(
|
|
"status",
|
|
"https://gateway.example/base/__openclaw__/cap/fixture-capability/__openclaw__/canvas/documents/status/index.html",
|
|
"scripts",
|
|
);
|
|
assert!(validate_widget_layout(&valid).is_ok());
|
|
assert!(validate_widget_layout(&test_widget(
|
|
"local",
|
|
"http://127.0.0.1:18789/__openclaw__/cap/fixture-capability/__openclaw__/canvas/documents/local/index.html",
|
|
"scripts",
|
|
))
|
|
.is_ok());
|
|
assert!(validate_widget_layout(&test_widget(
|
|
"local-v6",
|
|
"http://[::1]:18789/__openclaw__/cap/fixture-capability/__openclaw__/canvas/documents/local-v6/index.html",
|
|
"scripts",
|
|
))
|
|
.is_ok());
|
|
|
|
for url in [
|
|
"https://evil.example/widget.html",
|
|
"http://gateway.example/__openclaw__/cap/fixture-capability/__openclaw__/canvas/documents/status/index.html",
|
|
"https://gateway.example/__openclaw__/canvas/documents/status/index.html",
|
|
"https://gateway.example/__openclaw__/cap/fixture-capability/__openclaw__/canvas/documents/%252e%252e/private-file",
|
|
] {
|
|
assert!(validate_widget_layout(&test_widget("status", url, "scripts")).is_err());
|
|
}
|
|
let mut outside_window = valid.clone();
|
|
outside_window.y = QUICKCHAT_WIDGET_WINDOW_HEIGHT;
|
|
assert!(validate_widget_layout(&outside_window).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn labels_preserve_existing_instances_when_siblings_append() {
|
|
let first = test_widget(
|
|
"first",
|
|
"https://gateway.example/__openclaw__/cap/fixture-capability/__openclaw__/canvas/documents/first/index.html",
|
|
"scripts",
|
|
);
|
|
let second = test_widget(
|
|
"second",
|
|
"https://gateway.example/__openclaw__/cap/fixture-capability/__openclaw__/canvas/documents/second/index.html",
|
|
"scripts",
|
|
);
|
|
let first_label = widget_view_label(&first);
|
|
let desired_before = HashMap::from([(first.key.clone(), first_label.clone())]);
|
|
let desired_after = HashMap::from([
|
|
(first.key.clone(), widget_view_label(&first)),
|
|
(second.key.clone(), widget_view_label(&second)),
|
|
]);
|
|
|
|
assert_eq!(desired_after.get("first"), desired_before.get("first"));
|
|
let mut navigated = first.clone();
|
|
navigated.url.push_str("?revision=2");
|
|
assert_ne!(widget_view_label(&navigated), first_label);
|
|
}
|
|
|
|
#[test]
|
|
fn navigation_stays_on_the_original_document() {
|
|
let allowed = Url::parse(
|
|
"https://gateway.example/__openclaw__/cap/fixture-capability/__openclaw__/canvas/documents/status/index.html?mode=compact",
|
|
)
|
|
.expect("allowed URL");
|
|
let fragment = Url::parse(
|
|
"https://gateway.example/__openclaw__/cap/fixture-capability/__openclaw__/canvas/documents/status/index.html?mode=compact#details",
|
|
)
|
|
.expect("fragment URL");
|
|
let other = Url::parse(
|
|
"https://gateway.example/__openclaw__/cap/fixture-capability/__openclaw__/canvas/documents/other/index.html",
|
|
)
|
|
.expect("other URL");
|
|
let mut userinfo_url = allowed.clone();
|
|
userinfo_url
|
|
.set_username("fixture-user")
|
|
.expect("set fixture user");
|
|
|
|
assert!(same_widget_document(&fragment, &allowed));
|
|
assert!(!same_widget_document(&other, &allowed));
|
|
assert!(!same_widget_document(&userinfo_url, &allowed));
|
|
}
|
|
}
|